feat: keep project understanding in sync after import
This commit is contained in:
@@ -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<Project>, 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<Project>, fallback?: Project): Project {
|
||||
return project;
|
||||
}
|
||||
|
||||
function normalizeProjectUnderstanding(
|
||||
raw: Partial<ProjectUnderstandingSnapshot> | 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<BossState> | undefined): BossState {
|
||||
const base = cloneInitialState();
|
||||
if (!raw) return syncDerivedState(base);
|
||||
@@ -2943,6 +2993,11 @@ function normalizeState(raw: Partial<BossState> | 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<MasterAgentTask, "replyBody" | "deviceImportCandidateId" | "targetThreadDisplayName" | "deviceImportCandidateFolderName" | "taskId" | "completedAt" | "requestedAt">,
|
||||
): 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<MasterAgentTask, "replyBody" | "taskId" | "completedAt" | "requestedAt">,
|
||||
): 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: {
|
||||
|
||||
@@ -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 || "无",
|
||||
"",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user