From f69eebd82d483884b10d2176e2dd78f964eb8baa Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 4 Apr 2026 11:06:00 +0800 Subject: [PATCH] feat: sync thread status events --- src/lib/boss-data.ts | 315 +++++++++++++++++++++++++++++- tests/device-import-draft.test.ts | 121 ++++++------ tests/thread-status-sync.test.ts | 210 ++++++++++++++++++++ 3 files changed, 585 insertions(+), 61 deletions(-) create mode 100644 tests/thread-status-sync.test.ts diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 099042b..1b215cd 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -510,6 +510,52 @@ export interface ProjectUnderstandingSnapshot { sourceKind: "device_import" | "thread_sync"; } +export type ThreadStatusSourceKind = "device_import" | "full_sync" | "incremental_sync"; +export type ThreadProgressEventType = + | "phase_changed" + | "progress_updated" + | "blocker_added" + | "blocker_resolved" + | "next_step_changed" + | "architecture_updated" + | "handoff_ready"; + +export interface ThreadStatusDocument { + documentId: string; + projectId: string; + threadId: string; + threadDisplayName: string; + folderName: string; + deviceId: string; + projectGoal: string; + currentPhase: string; + currentProgress: string; + technicalArchitecture: string; + currentBlockers: string; + recommendedNextStep: string; + keyFiles: string[]; + keyCommands: string[]; + updatedAt: string; + sourceTaskId: string; + sourceKind: ThreadStatusSourceKind; +} + +export interface ThreadProgressEvent { + eventId: string; + projectId: string; + threadId: string; + threadDisplayName: string; + deviceId: string; + eventType: ThreadProgressEventType; + summary: string; + phase?: string; + blockerDelta?: string; + nextStepDelta?: string; + createdAt: string; + sourceTaskId: string; + sourceMessageId?: string; +} + export interface VerificationCode { id: string; account: string; @@ -931,6 +977,8 @@ export interface BossState { dispatchExecutions: DispatchExecution[]; deviceImportDrafts: DeviceImportDraft[]; deviceImportResolutions: DeviceImportResolution[]; + threadStatusDocuments: ThreadStatusDocument[]; + threadProgressEvents: ThreadProgressEvent[]; otaUpdates: OtaUpdate[]; otaUpdateLogs: OtaUpdateLog[]; deviceSkills: DeviceSkill[]; @@ -1532,6 +1580,8 @@ const initialState: BossState = { evidenceModes: ["serial_log"], }, ], + threadStatusDocuments: [], + threadProgressEvents: [], }; const levelPriority: Record = { @@ -2683,6 +2733,155 @@ function normalizeProjectUnderstanding( }; } +function normalizeThreadStatusSourceKind(value?: ThreadStatusSourceKind): ThreadStatusSourceKind { + return value === "device_import" || value === "full_sync" || value === "incremental_sync" + ? value + : "incremental_sync"; +} + +function normalizeThreadProgressEventType(value?: ThreadProgressEventType): ThreadProgressEventType { + return value === "phase_changed" || + value === "progress_updated" || + value === "blocker_added" || + value === "blocker_resolved" || + value === "next_step_changed" || + value === "architecture_updated" || + value === "handoff_ready" + ? value + : "progress_updated"; +} + +function compareThreadStatusDocuments(a: ThreadStatusDocument, b: ThreadStatusDocument) { + const updatedDelta = messageTimeValue(b.updatedAt) - messageTimeValue(a.updatedAt); + if (updatedDelta !== 0) return updatedDelta; + return b.documentId.localeCompare(a.documentId); +} + +function compareThreadProgressEvents(a: ThreadProgressEvent, b: ThreadProgressEvent) { + const createdDelta = messageTimeValue(b.createdAt) - messageTimeValue(a.createdAt); + if (createdDelta !== 0) return createdDelta; + return b.eventId.localeCompare(a.eventId); +} + +function threadProgressEventKey(event: ThreadProgressEvent) { + return `${event.projectId}:${event.threadId}`; +} + +function normalizeThreadStatusDocument( + raw: Partial, + fallback?: ThreadStatusDocument, +): ThreadStatusDocument { + const keyFiles = dedupeStrings( + ensureArray(raw.keyFiles as string[] | undefined, fallback?.keyFiles ?? []).map((item) => + item.trim(), + ), + ); + const keyCommands = dedupeStrings( + ensureArray(raw.keyCommands as string[] | undefined, fallback?.keyCommands ?? []).map((item) => + item.trim(), + ), + ); + return { + documentId: raw.documentId ?? fallback?.documentId ?? randomToken("thread-status"), + projectId: trimToDefined(raw.projectId ?? fallback?.projectId) ?? "", + threadId: trimToDefined(raw.threadId ?? fallback?.threadId) ?? "", + threadDisplayName: trimToDefined(raw.threadDisplayName ?? fallback?.threadDisplayName) ?? "", + folderName: trimToDefined(raw.folderName ?? fallback?.folderName) ?? "", + deviceId: trimToDefined(raw.deviceId ?? fallback?.deviceId) ?? "", + projectGoal: raw.projectGoal?.trim() ?? fallback?.projectGoal ?? "", + currentPhase: raw.currentPhase?.trim() ?? fallback?.currentPhase ?? "待整理", + currentProgress: raw.currentProgress?.trim() ?? fallback?.currentProgress ?? "", + technicalArchitecture: raw.technicalArchitecture?.trim() ?? fallback?.technicalArchitecture ?? "", + currentBlockers: raw.currentBlockers?.trim() ?? fallback?.currentBlockers ?? "", + recommendedNextStep: raw.recommendedNextStep?.trim() ?? fallback?.recommendedNextStep ?? "", + keyFiles, + keyCommands, + updatedAt: raw.updatedAt ?? fallback?.updatedAt ?? nowIso(), + sourceTaskId: trimToDefined(raw.sourceTaskId ?? fallback?.sourceTaskId) ?? randomToken("thread-status"), + sourceKind: normalizeThreadStatusSourceKind(raw.sourceKind ?? fallback?.sourceKind), + }; +} + +function normalizeThreadProgressEvent( + raw: Partial, + fallback?: ThreadProgressEvent, +): ThreadProgressEvent { + return { + eventId: raw.eventId ?? fallback?.eventId ?? randomToken("thread-event"), + projectId: trimToDefined(raw.projectId ?? fallback?.projectId) ?? "", + threadId: trimToDefined(raw.threadId ?? fallback?.threadId) ?? "", + threadDisplayName: trimToDefined(raw.threadDisplayName ?? fallback?.threadDisplayName) ?? "", + deviceId: trimToDefined(raw.deviceId ?? fallback?.deviceId) ?? "", + eventType: normalizeThreadProgressEventType(raw.eventType ?? fallback?.eventType), + summary: raw.summary?.trim() ?? fallback?.summary ?? "线程状态更新", + phase: trimToDefined(raw.phase ?? fallback?.phase), + blockerDelta: trimToDefined(raw.blockerDelta ?? fallback?.blockerDelta), + nextStepDelta: trimToDefined(raw.nextStepDelta ?? fallback?.nextStepDelta), + createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(), + sourceTaskId: trimToDefined(raw.sourceTaskId ?? fallback?.sourceTaskId) ?? randomToken("thread-event"), + sourceMessageId: trimToDefined(raw.sourceMessageId ?? fallback?.sourceMessageId), + }; +} + +function buildHeartbeatProgressSummary(threadDisplayName: string) { + return `检测到线程有新活动:${threadDisplayName}`; +} + +function summarizeThreadReplyBody(body: string) { + const normalized = body + .replace(/\s+/g, " ") + .trim(); + if (!normalized) { + return "线程状态更新"; + } + return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized; +} + +function upsertThreadStatusDocumentInState( + state: BossState, + input: { + projectId: string; + threadId: string; + threadDisplayName: string; + folderName: string; + deviceId: string; + projectGoal: string; + currentPhase: string; + currentProgress: string; + technicalArchitecture: string; + currentBlockers: string; + recommendedNextStep: string; + keyFiles: string[]; + keyCommands: string[]; + updatedAt: string; + sourceTaskId: string; + sourceKind: ThreadStatusSourceKind; + }, +) { + const existing = state.threadStatusDocuments.find( + (item) => item.projectId === input.projectId && item.threadId === input.threadId, + ); + const document = normalizeThreadStatusDocument(input, existing); + if (existing) { + Object.assign(existing, document); + return existing; + } + state.threadStatusDocuments.unshift(document); + return document; +} + +function appendThreadProgressEventInState( + state: BossState, + input: Omit, +) { + const event = normalizeThreadProgressEvent({ + eventId: randomToken("thread-event"), + ...input, + }); + state.threadProgressEvents.unshift(event); + return event; +} + function normalizeState(raw: Partial | undefined): BossState { const base = cloneInitialState(); if (!raw) return syncDerivedState(base); @@ -2850,6 +3049,24 @@ function normalizeState(raw: Partial | undefined): BossState { base.deviceImportResolutions[index % Math.max(1, base.deviceImportResolutions.length)], ), ), + threadStatusDocuments: ensureArray( + raw.threadStatusDocuments as Partial[] | undefined, + base.threadStatusDocuments, + ).map((document, index) => + normalizeThreadStatusDocument( + document, + base.threadStatusDocuments[index % Math.max(1, base.threadStatusDocuments.length)], + ), + ), + threadProgressEvents: ensureArray( + raw.threadProgressEvents as Partial[] | undefined, + base.threadProgressEvents, + ).map((event, index) => + normalizeThreadProgressEvent( + event, + base.threadProgressEvents[index % Math.max(1, base.threadProgressEvents.length)], + ), + ), otaUpdates: ensureArray(raw.otaUpdates, base.otaUpdates).map((update, index) => ({ ...base.otaUpdates[index % base.otaUpdates.length], ...update, @@ -3001,6 +3218,12 @@ function removeLegacyBossConsoleArtifacts(state: BossState) { state.threadContextAlerts = state.threadContextAlerts.filter( (item) => !isLegacyBossConsoleRef(item.projectId), ); + state.threadStatusDocuments = state.threadStatusDocuments.filter( + (item) => !isLegacyBossConsoleRef(item.projectId), + ); + state.threadProgressEvents = state.threadProgressEvents.filter( + (item) => !isLegacyBossConsoleRef(item.projectId), + ); state.opsFaults = state.opsFaults.filter((item) => !isLegacyBossConsoleRef(item.projectId)); state.masterAgentTasks = state.masterAgentTasks.filter( (task) => @@ -3448,6 +3671,37 @@ function syncDerivedState(input: BossState) { state.deviceImportResolutions = state.deviceImportResolutions.filter( (item) => visibleDeviceIds.has(item.deviceId) && visibleImportDraftIds.has(item.draftId), ); + const visibleProjectIds = new Set(state.projects.map((project) => project.id)); + const threadStatusDocumentByThread = new Map(); + const normalizedThreadStatusDocuments = state.threadStatusDocuments.map((document) => + normalizeThreadStatusDocument(document), + ); + for (const document of normalizedThreadStatusDocuments + .filter((item) => visibleProjectIds.has(item.projectId) && visibleDeviceIds.has(item.deviceId)) + .sort(compareThreadStatusDocuments)) { + const key = `${document.projectId}:${document.threadId}`; + if (!threadStatusDocumentByThread.has(key)) { + threadStatusDocumentByThread.set(key, document); + } + } + state.threadStatusDocuments = [...threadStatusDocumentByThread.values()].slice(0, 80); + const progressEventCounts = new Map(); + const normalizedThreadProgressEvents = state.threadProgressEvents.map((event) => + normalizeThreadProgressEvent(event), + ); + state.threadProgressEvents = normalizedThreadProgressEvents + .filter((item) => visibleProjectIds.has(item.projectId) && visibleDeviceIds.has(item.deviceId)) + .sort(compareThreadProgressEvents) + .filter((item) => { + const key = threadProgressEventKey(item); + const nextCount = (progressEventCounts.get(key) ?? 0) + 1; + if (nextCount > 20) { + return false; + } + progressEventCounts.set(key, nextCount); + return true; + }) + .slice(0, 400); state.deviceSkills = state.deviceSkills .filter((skill) => visibleDeviceIds.has(skill.deviceId)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); @@ -6883,6 +7137,16 @@ export async function upsertDeviceHeartbeat(payload: { matchingProject.threadMeta.lastObservedCodexActivityAt, candidate.lastActiveAt, ) ?? candidate.lastActiveAt; + appendThreadProgressEventInState(state, { + projectId: matchingProject.id, + threadId: matchingProject.threadMeta.threadId, + threadDisplayName: matchingProject.threadMeta.threadDisplayName, + deviceId: matchingProject.deviceIds[0] ?? payload.deviceId, + eventType: "progress_updated", + summary: buildHeartbeatProgressSummary(candidate.threadDisplayName), + createdAt: candidate.lastActiveAt, + sourceTaskId: `heartbeat-${candidate.candidateId}`, + }); if (shouldQueueProjectUnderstandingSync(matchingProject, candidate.lastActiveAt, state)) { projectUnderstandingSyncRequests.push({ projectId: matchingProject.id, @@ -7193,6 +7457,24 @@ function applyProjectUnderstandingSnapshotInState( sourceKind: input.sourceKind, }; project.projectUnderstanding = snapshot; + upsertThreadStatusDocumentInState(state, { + projectId: project.id, + threadId: project.threadMeta.threadId, + threadDisplayName: project.threadMeta.threadDisplayName, + folderName: project.threadMeta.folderName, + deviceId: project.deviceIds[0] ?? state.user.boundDeviceId ?? PRIMARY_CODEX_NODE_ID, + projectGoal: snapshot.projectGoal, + currentPhase: input.sourceKind === "device_import" ? "导入理解" : "全量理解", + currentProgress: snapshot.currentProgress, + technicalArchitecture: snapshot.technicalArchitecture, + currentBlockers: snapshot.currentBlockers, + recommendedNextStep: snapshot.recommendedNextStep, + keyFiles: [], + keyCommands: [], + updatedAt: snapshot.updatedAt, + sourceTaskId: snapshot.sourceTaskId, + sourceKind: input.sourceKind === "device_import" ? "device_import" : "full_sync", + }); project.threadMeta.lastProjectUnderstandingSyncedAt = snapshot.updatedAt; project.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp(project.threadMeta.lastObservedCodexActivityAt, snapshot.updatedAt) ?? snapshot.updatedAt; @@ -7322,6 +7604,12 @@ function shouldQueueProjectUnderstandingSync(project: Project, observedActivityA if (Number.isFinite(latestWatermark) && observedTs <= latestWatermark) { return false; } + const hasThreadStatusDocument = state.threadStatusDocuments.some( + (item) => item.projectId === project.id && item.threadId === project.threadMeta.threadId, + ); + if (project.projectUnderstanding && hasThreadStatusDocument) { + return false; + } return !state.masterAgentTasks.some( (task) => task.taskType === "conversation_reply" && @@ -8282,12 +8570,33 @@ export async function appendProjectMessage(payload: { project.lastMessageAt = message.sentAt; project.preview = message.body; + const shouldTrackThreadProgress = + payload.sender !== "user" && + isDispatchableThreadProject(project) && + Boolean(project.threadMeta.codexThreadRef?.trim()); + if (shouldTrackThreadProgress) { + project.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp( + project.threadMeta.lastObservedCodexActivityAt, + message.sentAt, + ) ?? message.sentAt; + appendThreadProgressEventInState(state, { + projectId: project.id, + threadId: project.threadMeta.threadId, + threadDisplayName: project.threadMeta.threadDisplayName, + deviceId: project.deviceIds[0] ?? project.id, + eventType: "progress_updated", + summary: summarizeThreadReplyBody(message.body), + phase: project.projectUnderstanding ? "增量同步" : "线程回复", + createdAt: message.sentAt, + sourceTaskId: message.id, + sourceMessageId: message.id, + }); + } + return { message, shouldQueueUnderstandingSync: - payload.sender !== "user" && - isDispatchableThreadProject(project) && - Boolean(project.threadMeta.codexThreadRef?.trim()), + shouldTrackThreadProgress && shouldQueueProjectUnderstandingSync(project, message.sentAt, state), }; }); if (result.shouldQueueUnderstandingSync) { diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts index 1b73a70..2f4e81f 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -342,6 +342,55 @@ test("device import draft review queues a master-agent task, then completion wri const device = nextState.devices.find((item) => item.id === enrollmentPayload.device.id); assert.deepEqual(device?.projects, ["北区试产线"]); + const progressEventCountBefore = nextState.threadProgressEvents.filter( + (event) => event.projectId === importedProject?.id, + ).length; + const followupHeartbeatResponse = 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 mini", + avatar: "M", + account: "17600003315", + status: "online", + quota5h: 73, + quota7d: 84, + projects: ["北区试产线"], + endpoint: "mac://mini.local", + projectCandidates: [ + { + folderName: "北区试产线", + folderRef: "north-line", + threadId: "thread-north-regression", + threadDisplayName: "北区试产线回归", + codexFolderRef: "north-line", + codexThreadRef: "thread-north-regression", + lastActiveAt: "2026-03-30T12:00:00+08:00", + suggestedImport: true, + }, + ], + }), + }), + ); + assert.equal(followupHeartbeatResponse.status, 200); + + const afterHeartbeatState = await readState(); + const progressEvents = afterHeartbeatState.threadProgressEvents.filter( + (event) => event.projectId === importedProject?.id, + ); + assert.equal(progressEvents.length, progressEventCountBefore + 1); + assert.equal(progressEvents[0]?.eventType, "progress_updated"); + assert.match(progressEvents[0]?.summary ?? "", /北区试产线回归|新活动/); + assert.equal( + afterHeartbeatState.masterAgentTasks.some( + (task) => task.projectUnderstandingTargetProjectId === importedProject?.id && task.status === "queued", + ), + false, + ); + const appliedDraft = nextState.deviceImportDrafts.find( (draft) => draft.deviceId === enrollmentPayload.device.id, ); @@ -527,6 +576,9 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac ); assert.ok(importedProject); assert.equal(importedProject?.projectUnderstanding?.currentProgress, "已经完成导入前梳理,准备开始界面和设备联调。"); + const progressEventsBefore = currentState.threadProgressEvents.filter( + (event) => event.projectId === importedProject?.id, + ).length; const secondHeartbeatResponse = await deviceHeartbeatRoute( new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { @@ -555,42 +607,19 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac task.projectUnderstandingReason === "heartbeat_activity" && task.status === "queued", ); - assert.ok(hiddenSyncTask, "expected a hidden follow-up sync task for newer thread activity"); + assert.equal(hiddenSyncTask, undefined); - 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, + const progressEventsAfter = currentState.threadProgressEvents.filter( + (event) => event.projectId === importedProject?.id, ); + assert.equal(progressEventsAfter.length, progressEventsBefore + 1); + assert.equal(progressEventsAfter[0]?.eventType, "progress_updated"); + assert.match(progressEventsAfter[0]?.summary ?? "", /北区试产线回归|新活动/); - 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.equal(refreshedProject?.projectUnderstanding?.currentProgress, "已经完成导入前梳理,准备开始界面和设备联调。"); + assert.match(refreshedProject?.projectUnderstanding?.technicalArchitecture ?? "", /Android 原生端连接 Boss Web/); + assert.equal(refreshedProject?.projectUnderstanding?.sourceKind, "device_import"); assert.ok(refreshedProject?.threadMeta.lastProjectUnderstandingSyncedAt); assert.equal( @@ -599,7 +628,7 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac memory.projectId === refreshedProject?.id && memory.title === "项目进度 · 智能看板主线程", )?.content, - "用户已经继续推进到实时状态同步和 UI 联调阶段。", + "已经完成导入前梳理,准备开始界面和设备联调。", ); assert.equal( currentState.masterAgentMemories.find( @@ -607,32 +636,8 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac memory.projectId === refreshedProject?.id && memory.title === "下一步建议 · 智能看板主线程", )?.content, - "优先压平实时状态刷新抖动,再验证群聊调度链路。", + "先对齐状态推送协议,再做前后端联调。", ); - const masterAgentProject = currentState.projects.find((project) => project.id === "master-agent"); - const syncNotice = masterAgentProject?.messages.findLast( - (message) => - message.kind === "system_notice" && - /已同步项目理解:智能看板主线程/.test(message.body) && - /实时状态同步和 UI 联调阶段/.test(message.body), - ); - assert.ok(syncNotice, "expected master-agent conversation to receive a lightweight sync digest"); - const nextStepNotice = masterAgentProject?.messages.findLast( - (message) => - message.kind === "system_notice" && - /建议下一步推进:智能看板主线程/.test(message.body) && - /优先压平实时状态刷新抖动,再验证群聊调度链路。/.test(message.body), - ); - assert.ok(nextStepNotice, "expected master-agent conversation to receive a lightweight next-step suggestion"); - const takeoverNotice = masterAgentProject?.messages.findLast( - (message) => - message.kind === "system_notice" && - /主 Agent 可接手:智能看板主线程/.test(message.body) && - /已掌握当前目标、进度、架构与阻塞,可继续推进:优先压平实时状态刷新抖动,再验证群聊调度链路。/.test( - message.body, - ), - ); - assert.ok(takeoverNotice, "expected master-agent conversation to receive a lightweight takeover suggestion"); }); test("heartbeat candidates no longer auto-create chat windows from legacy projects when import draft is present", async () => { diff --git a/tests/thread-status-sync.test.ts b/tests/thread-status-sync.test.ts new file mode 100644 index 0000000..947cbe9 --- /dev/null +++ b/tests/thread-status-sync.test.ts @@ -0,0 +1,210 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import type { BossState, Project, ThreadProgressEvent, ThreadStatusDocument } from "../src/lib/boss-data.ts"; + +let runtimeRoot = ""; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let appendProjectMessage: (typeof import("../src/lib/boss-data"))["appendProjectMessage"]; + +type MutableBossState = BossState & { + threadStatusDocuments: ThreadStatusDocument[]; + threadProgressEvents: ThreadProgressEvent[]; + projects: Project[]; +}; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-thread-status-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const data = await import("../src/lib/boss-data.ts"); + readState = data.readState; + writeState = data.writeState; + appendProjectMessage = data.appendProjectMessage; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("thread status documents and progress events normalize, sort, and trim correctly", async () => { + await setup(); + + const state = (await readState()) as MutableBossState; + const baseDocTime = Date.parse("2026-04-04T10:00:00.000Z"); + state.threadStatusDocuments = Array.from({ length: 81 }, (_, index) => ({ + documentId: `doc-${index}`, + projectId: "master-agent", + threadId: `thread-${index}`, + threadDisplayName: index === 80 ? " 树莓派二代查询 " : `线程 ${index}`, + folderName: index === 80 ? " Talking " : `文件夹 ${index}`, + deviceId: " mac-studio ", + projectGoal: index === 80 ? " 完成树莓派二代查询链路 " : `目标 ${index}`, + currentPhase: index === 80 ? " 功能实现 " : `阶段 ${index}`, + currentProgress: index === 80 ? " 已完成排序修复 " : `进度 ${index}`, + technicalArchitecture: index === 80 ? " Next.js API + Android 原生客户端 " : `架构 ${index}`, + currentBlockers: index === 80 ? " " : `阻塞 ${index}`, + recommendedNextStep: index === 80 ? " 补会话页展示与排序 " : `下一步 ${index}`, + keyFiles: index === 80 ? [" src/lib/boss-data.ts ", " tests/thread-status-sync.test.ts "] : [`file-${index}.ts`], + keyCommands: index === 80 ? [" npm run build ", " npm run lint "] : [`cmd-${index}`], + updatedAt: new Date(baseDocTime + index * 60_000).toISOString(), + sourceTaskId: `task-${index}`, + sourceKind: index === 80 ? undefined : "full_sync", + })); + state.threadProgressEvents = [ + ...Array.from({ length: 25 }, (_, index) => ({ + eventId: `event-a-${index}`, + projectId: "master-agent", + threadId: "thread-a", + threadDisplayName: "线程 A", + deviceId: "mac-studio", + eventType: "progress_updated", + summary: `线程 A 进展 ${index}`, + phase: "功能实现", + createdAt: `2026-04-04T19:${String(index).padStart(2, "0")}:00+08:00`, + sourceTaskId: `task-a-${index}`, + })), + ...Array.from({ length: 381 }, (_, index) => ({ + eventId: `event-b-${index}`, + projectId: "master-agent", + threadId: `thread-b-${index}`, + threadDisplayName: `线程 B${index}`, + deviceId: "mac-studio", + eventType: "progress_updated", + summary: `线程 B${index} 进展`, + createdAt: `2026-04-04T17:${String(index % 60).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}+08:00`, + sourceTaskId: `task-b-${index}`, + })), + ]; + + await writeState(state); + const normalized = (await readState()) as MutableBossState; + + assert.equal(normalized.threadStatusDocuments.length, 80); + assert.equal(normalized.threadStatusDocuments[0]?.documentId, "doc-80"); + assert.equal(normalized.threadStatusDocuments[0]?.threadDisplayName, "树莓派二代查询"); + assert.equal(normalized.threadStatusDocuments[0]?.folderName, "Talking"); + assert.equal(normalized.threadStatusDocuments[0]?.deviceId, "mac-studio"); + assert.equal(normalized.threadStatusDocuments[0]?.projectGoal, "完成树莓派二代查询链路"); + assert.equal(normalized.threadStatusDocuments[0]?.currentPhase, "功能实现"); + assert.deepEqual(normalized.threadStatusDocuments[0]?.keyFiles, [ + "src/lib/boss-data.ts", + "tests/thread-status-sync.test.ts", + ]); + assert.deepEqual(normalized.threadStatusDocuments[0]?.keyCommands, ["npm run build", "npm run lint"]); + assert.equal(normalized.threadStatusDocuments[0]?.sourceKind, "incremental_sync"); + + assert.equal(normalized.threadProgressEvents.length, 400); + assert.equal(normalized.threadProgressEvents[0]?.eventId, "event-a-24"); + assert.equal( + normalized.threadProgressEvents.filter((event) => event.projectId === "master-agent" && event.threadId === "thread-a") + .length, + 20, + ); +}); + +test("thread replies append lightweight progress events without queuing a fresh understanding sync", async () => { + await setup(); + + const state = (await readState()) as MutableBossState; + state.threadProgressEvents = []; + state.projects.push({ + id: "thread-sync-demo", + name: "线程状态演示", + pinned: false, + deviceIds: ["mac-studio"], + preview: "初始状态", + updatedAt: "2026-04-04T18:00:00+08:00", + lastMessageAt: "2026-04-04T18:00:00+08:00", + isGroup: false, + threadMeta: { + projectId: "thread-sync-demo", + threadId: "thread-sync-demo-thread", + threadDisplayName: "线程状态演示", + folderName: "演示文件夹", + activityIconCount: 1, + updatedAt: "2026-04-04T18:00:00+08:00", + lastObservedCodexActivityAt: "2026-04-04T18:00:00+08:00", + lastProjectUnderstandingRequestedAt: "2026-04-04T17:00:00+08:00", + lastProjectUnderstandingSyncedAt: "2026-04-04T18:00:00+08:00", + codexThreadRef: "thread-sync-demo-thread", + codexFolderRef: "thread-sync-demo-folder", + }, + groupMembers: [], + createdByAgent: false, + collaborationMode: "development", + approvalState: "not_required", + unreadCount: 0, + riskLevel: "low", + projectUnderstanding: { + projectGoal: "完成线程状态回归", + currentProgress: "旧进度", + technicalArchitecture: "旧架构", + currentBlockers: "", + recommendedNextStep: "旧下一步", + sourceTaskId: "task-old", + updatedAt: "2026-04-04T18:00:00+08:00", + sourceKind: "thread_sync", + }, + messages: [], + goals: [], + versions: [], + } as Project); + state.threadStatusDocuments = [ + { + documentId: "doc-old", + projectId: "thread-sync-demo", + threadId: "thread-sync-demo-thread", + threadDisplayName: "线程状态演示", + folderName: "演示文件夹", + deviceId: "mac-studio", + projectGoal: "完成线程状态回归", + currentPhase: "全量理解", + currentProgress: "旧进度", + technicalArchitecture: "旧架构", + currentBlockers: "", + recommendedNextStep: "旧下一步", + keyFiles: ["src/lib/boss-data.ts"], + keyCommands: ["npm run build"], + updatedAt: "2026-04-04T18:00:00+08:00", + sourceTaskId: "task-old", + sourceKind: "full_sync", + }, + ]; + + await writeState(state); + const before = (await readState()) as MutableBossState; + const beforeCount = before.threadProgressEvents.filter( + (event) => event.projectId === "thread-sync-demo", + ).length; + + const message = await appendProjectMessage({ + projectId: "thread-sync-demo", + sender: "device", + senderLabel: "线程执行器", + body: "已完成手机端排序修复", + kind: "text", + }); + assert.equal(message.body, "已完成手机端排序修复"); + + const after = (await readState()) as MutableBossState; + const events = after.threadProgressEvents.filter((event) => event.projectId === "thread-sync-demo"); + assert.equal(events.length, beforeCount + 1); + assert.equal(events[0]?.summary, "已完成手机端排序修复"); + assert.equal(events[0]?.eventType, "progress_updated"); + assert.equal( + after.masterAgentTasks.some( + (task) => + task.projectUnderstandingTargetProjectId === "thread-sync-demo" && task.status === "queued", + ), + false, + ); +});