feat: sync thread status events
This commit is contained in:
@@ -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<ContextBudgetLevel, number> = {
|
||||
@@ -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<ThreadStatusDocument>,
|
||||
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<ThreadProgressEvent>,
|
||||
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<ThreadProgressEvent, "eventId">,
|
||||
) {
|
||||
const event = normalizeThreadProgressEvent({
|
||||
eventId: randomToken("thread-event"),
|
||||
...input,
|
||||
});
|
||||
state.threadProgressEvents.unshift(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
const base = cloneInitialState();
|
||||
if (!raw) return syncDerivedState(base);
|
||||
@@ -2850,6 +3049,24 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
base.deviceImportResolutions[index % Math.max(1, base.deviceImportResolutions.length)],
|
||||
),
|
||||
),
|
||||
threadStatusDocuments: ensureArray(
|
||||
raw.threadStatusDocuments as Partial<ThreadStatusDocument>[] | undefined,
|
||||
base.threadStatusDocuments,
|
||||
).map((document, index) =>
|
||||
normalizeThreadStatusDocument(
|
||||
document,
|
||||
base.threadStatusDocuments[index % Math.max(1, base.threadStatusDocuments.length)],
|
||||
),
|
||||
),
|
||||
threadProgressEvents: ensureArray(
|
||||
raw.threadProgressEvents as Partial<ThreadProgressEvent>[] | 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<string, ThreadStatusDocument>();
|
||||
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<string, number>();
|
||||
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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
210
tests/thread-status-sync.test.ts
Normal file
210
tests/thread-status-sync.test.ts
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user