fix(agent): harden detail replacement recovery

This commit is contained in:
134 2026-07-31 20:05:18 +08:00
parent 3c6b7b7778
commit 8b4fd29fce
4 changed files with 583 additions and 51 deletions

View File

@ -143,6 +143,7 @@ import {
normalizeAgentGenerationProjectPersistedState,
normalizeAgentGenerationProjectReplacementResultState,
} from "@/lib/agents/generation-project-state";
import { doesAgentProjectStateCoverReplacementResults } from "@/lib/agents/agent-project-save-merge";
import {
createEmptyAgentModeWorkspaceState,
normalizeAgentModeWorkspaceState,
@ -1552,22 +1553,6 @@ function areReplacementResultStatesEqual(
);
}
function getReplacementResultUrlCount(
state: AgentGenerationProjectPersistedState | undefined | null
) {
const normalized = normalizeAgentGenerationProjectReplacementResultState(
state?.replacementResultState
);
return (
Object.values(normalized.detail_replacement || {}).filter((record) =>
Boolean(record.url)
).length +
Object.values(normalized.sku_replacement || {}).filter((record) =>
Boolean(record.url)
).length
);
}
function hasReplacementResultCollectionMismatch(
state: AgentGenerationProjectPersistedState | undefined | null
) {
@ -4447,26 +4432,18 @@ export default function CanvasEditorLegacyPage({
const serverUpdatedAt =
Number(new Date(result.data.updatedAt).getTime()) || 0;
const serverReplacementResultCount = getReplacementResultUrlCount(
result.data.projectState
);
const localReplacementResultCount = getReplacementResultUrlCount(
localDraft?.projectState
);
const localDraftMissesServerReplacementResults =
serverReplacementResultCount > 0 && localReplacementResultCount === 0;
const localDraftReplacementResultsDifferFromServer =
serverReplacementResultCount > 0 &&
localReplacementResultCount > 0 &&
localReplacementResultCount !== serverReplacementResultCount;
!doesAgentProjectStateCoverReplacementResults({
candidateState: localDraft?.projectState,
authoritativeState: result.data.projectState,
});
const localDraftHasStaleReplacementCollections =
serverReplacementResultCount > 0 &&
!localDraftMissesServerReplacementResults &&
hasReplacementResultCollectionMismatch(localDraft?.projectState);
const shouldUseLocalDraft =
Boolean(localDraft?.projectState) &&
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
!localDraftMissesServerReplacementResults &&
!localDraftReplacementResultsDifferFromServer &&
!localDraftHasStaleReplacementCollections;
if (shouldUseLocalDraft && localDraft?.projectState) {

View File

@ -1,6 +1,8 @@
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
import type {
AgentDetailReplacementReferenceItem,
AgentGenerationProjectPersistedState,
AgentGenerationReplacementResultRecord,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections,
AgentWorkflowBusinessModeKey,
@ -90,7 +92,31 @@ function mergeReplacementResultState(
(next, key) => {
const existingModeState = existing?.[key] || {};
const incomingModeState = incoming?.[key] || {};
const mergedModeState = { ...existingModeState, ...incomingModeState };
const mergedModeState = Array.from(
new Set([...Object.keys(existingModeState), ...Object.keys(incomingModeState)])
).reduce<Record<string, AgentGenerationReplacementResultRecord>>(
(records, cardId) => {
const existingRecord = existingModeState[cardId];
const incomingRecord = incomingModeState[cardId];
if (!existingRecord) {
if (incomingRecord) {
records[cardId] = incomingRecord;
}
return records;
}
if (!incomingRecord) {
records[cardId] = existingRecord;
return records;
}
const existingUpdatedAt = Number(existingRecord.updatedAt) || 0;
const incomingUpdatedAt = Number(incomingRecord.updatedAt) || 0;
records[cardId] =
incomingUpdatedAt >= existingUpdatedAt ? incomingRecord : existingRecord;
return records;
},
{}
);
next[key] = Object.fromEntries(
Object.entries(mergedModeState).filter(([, record]) => {
const url = String(record?.url || "").trim();
@ -113,6 +139,33 @@ function getReplacementResultUrls(
);
}
export function getReplacementResultUrlsFromAgentProjectState(
state: AgentGenerationProjectPersistedState | null | undefined
) {
const normalized = normalizeAgentGenerationProjectPersistedState(state);
return normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(
normalized.resultCollections?.detail_replacement
),
...normalizeProjectResultUrlList(
normalized.resultCollections?.sku_replacement
),
...getReplacementResultUrls(normalized.replacementResultState),
]);
}
export function doesAgentProjectStateCoverReplacementResults(params: {
candidateState: AgentGenerationProjectPersistedState | null | undefined;
authoritativeState: AgentGenerationProjectPersistedState | null | undefined;
}) {
const candidateUrls = new Set(
getReplacementResultUrlsFromAgentProjectState(params.candidateState)
);
return getReplacementResultUrlsFromAgentProjectState(
params.authoritativeState
).every((url) => candidateUrls.has(url));
}
export function getAllResultUrlsFromAgentProjectState(
state: AgentGenerationProjectPersistedState
) {
@ -172,29 +225,81 @@ function mergeWorkflowStateForSave(params: {
}) {
const incomingAgentModeState = params.incomingState.workflowState.agentModeState;
const existingAgentModeState = params.existingState.workflowState.agentModeState;
const shouldKeepDetailReplacementState =
hasDetailReplacementResultSignals(params) &&
(existingAgentModeState?.detailReplacement?.references?.length || 0) > 0 &&
(incomingAgentModeState?.detailReplacement?.references?.length || 0) === 0;
const shouldKeepSkuReplacementState =
hasSkuReplacementResultSignals(params) &&
(existingAgentModeState?.skuReplacement?.references?.length || 0) > 0 &&
(incomingAgentModeState?.skuReplacement?.references?.length || 0) === 0;
const mergeProtectedReferences = (
existingReferences: AgentDetailReplacementReferenceItem[],
incomingReferences: AgentDetailReplacementReferenceItem[],
resultRecords: Record<string, AgentGenerationReplacementResultRecord>,
hasResultSignals: boolean
) => {
if (!hasResultSignals || existingReferences.length === 0) {
return incomingReferences;
}
if (!shouldKeepDetailReplacementState && !shouldKeepSkuReplacementState) {
return params.incomingState.workflowState;
}
const protectedCardIds = new Set(
Object.entries(resultRecords)
.filter(([, record]) => Boolean(record?.url))
.map(([cardId]) => cardId)
);
const existingReferenceIds = new Set(
existingReferences.map((reference) => reference.id)
);
const incomingIsExistingSubset =
incomingReferences.length < existingReferences.length &&
incomingReferences.every((reference) =>
existingReferenceIds.has(reference.id)
);
const protectedExistingReferences =
incomingReferences.length === 0 || incomingIsExistingSubset
? existingReferences
: protectedCardIds.size > 0
? existingReferences.filter((reference) =>
protectedCardIds.has(reference.id)
)
: [];
if (protectedExistingReferences.length === 0) {
return incomingReferences;
}
const protectedById = new Map(
protectedExistingReferences.map((reference) => [reference.id, reference])
);
const mergedReferences = incomingReferences.map(
(reference) => protectedById.get(reference.id) || reference
);
const mergedIds = new Set(mergedReferences.map((reference) => reference.id));
protectedExistingReferences.forEach((reference) => {
if (!mergedIds.has(reference.id)) {
mergedReferences.push(reference);
}
});
return mergedReferences;
};
const detailReferences = mergeProtectedReferences(
existingAgentModeState?.detailReplacement?.references || [],
incomingAgentModeState?.detailReplacement?.references || [],
params.replacementResultState.detail_replacement || {},
hasDetailReplacementResultSignals(params)
);
const skuReferences = mergeProtectedReferences(
existingAgentModeState?.skuReplacement?.references || [],
incomingAgentModeState?.skuReplacement?.references || [],
params.replacementResultState.sku_replacement || {},
hasSkuReplacementResultSignals(params)
);
return {
...params.incomingState.workflowState,
agentModeState: {
...incomingAgentModeState,
detailReplacement: shouldKeepDetailReplacementState
? existingAgentModeState?.detailReplacement
: incomingAgentModeState?.detailReplacement,
skuReplacement: shouldKeepSkuReplacementState
? existingAgentModeState?.skuReplacement
: incomingAgentModeState?.skuReplacement,
detailReplacement: {
...incomingAgentModeState?.detailReplacement,
references: detailReferences,
},
skuReplacement: {
...incomingAgentModeState?.skuReplacement,
references: skuReferences,
},
},
};
}

View File

@ -13,6 +13,7 @@ import type {
AgentGenerationResultModeType,
AgentGenerationReplacementResultStatus,
AgentGenerationProjectWorkspaceState,
AgentDetailReplacementReferenceItem,
AgentWorkflowBusinessModeKey,
AgentWorkflowPersistedState,
} from "@/types/agent";
@ -214,6 +215,157 @@ function reconcileReplacementResultCollections(params: {
} satisfies AgentGenerationProjectResultCollections;
}
const REPLACEMENT_MODE_TYPES = [
"detail_replacement",
"sku_replacement",
] as const;
function getReplacementReferences(
workflowState: AgentWorkflowPersistedState,
modeType: (typeof REPLACEMENT_MODE_TYPES)[number]
) {
return modeType === "detail_replacement"
? workflowState.agentModeState?.detailReplacement?.references || []
: workflowState.agentModeState?.skuReplacement?.references || [];
}
function reconcileReplacementResultState(params: {
workflowState: AgentWorkflowPersistedState;
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
const nextState: AgentGenerationProjectReplacementResultState = {
detail_replacement: {
...(params.replacementResultState.detail_replacement || {}),
},
sku_replacement: {
...(params.replacementResultState.sku_replacement || {}),
},
};
REPLACEMENT_MODE_TYPES.forEach((modeType) => {
const modeState = nextState[modeType] || {};
const hasPersistedResultRecord = Object.values(modeState).some((record) =>
Boolean(record?.url)
);
if (hasPersistedResultRecord) {
return;
}
const resultUrls = params.resultCollections[modeType] || [];
if (resultUrls.length === 0) {
return;
}
const references = getReplacementReferences(params.workflowState, modeType);
const usedCardIds = new Set(Object.keys(modeState));
const nextModeState = { ...modeState };
resultUrls.forEach((resultUrl, index) => {
const reference = references[index] || null;
const preferredCardId = String(reference?.id || "").trim();
const cardId =
preferredCardId && !usedCardIds.has(preferredCardId)
? preferredCardId
: `${modeType}-legacy-result-${index + 1}`;
usedCardIds.add(cardId);
nextModeState[cardId] = {
url: resultUrl,
text: "",
status: "completed",
updatedAt: 0,
compareSourceUrl: reference?.imageUrl || reference?.previewUrl || null,
referenceImageUrl: reference?.imageUrl || null,
referencePreviewUrl: reference?.previewUrl || null,
label: reference?.label || `历史结果 ${index + 1}`,
};
});
nextState[modeType] = nextModeState;
});
return nextState;
}
function buildRecoveredReplacementReference(params: {
cardId: string;
index: number;
record: NonNullable<
AgentGenerationProjectReplacementResultState["detail_replacement"]
>[string];
}): AgentDetailReplacementReferenceItem {
const referenceImageUrl =
params.record.referenceImageUrl ||
params.record.referencePreviewUrl ||
params.record.compareSourceUrl ||
null;
return {
id: params.cardId,
label: params.record.label || `历史结果 ${params.index + 1}`,
imageUrl: referenceImageUrl,
previewUrl: params.record.referencePreviewUrl || referenceImageUrl,
sortOrder: params.index,
};
}
function reconcileWorkflowReplacementReferences(params: {
workflowState: AgentWorkflowPersistedState;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
const agentModeState =
params.workflowState.agentModeState || createEmptyAgentModeWorkspaceState();
const reconcileModeReferences = (
modeType: (typeof REPLACEMENT_MODE_TYPES)[number],
references: AgentDetailReplacementReferenceItem[]
) => {
const existingIds = new Set(references.map((reference) => reference.id));
const recovered = Object.entries(
params.replacementResultState[modeType] || {}
)
.filter(([cardId, record]) => cardId && Boolean(record?.url) && !existingIds.has(cardId))
.map(([cardId, record], index) =>
buildRecoveredReplacementReference({
cardId,
index: references.length + index,
record,
})
);
return recovered.length > 0 ? [...references, ...recovered] : references;
};
const detailReferences = reconcileModeReferences(
"detail_replacement",
agentModeState.detailReplacement.references
);
const skuReferences = reconcileModeReferences(
"sku_replacement",
agentModeState.skuReplacement.references
);
if (
detailReferences === agentModeState.detailReplacement.references &&
skuReferences === agentModeState.skuReplacement.references
) {
return params.workflowState;
}
return {
...params.workflowState,
agentModeState: {
...agentModeState,
detailReplacement: {
...agentModeState.detailReplacement,
references: detailReferences,
},
skuReplacement: {
...agentModeState.skuReplacement,
references: skuReferences,
},
},
};
}
function inferLegacyResultModeType(
workflowState: AgentWorkflowPersistedState,
resultUrls: string[]
@ -452,19 +604,32 @@ export function normalizeAgentGenerationProjectPersistedState(
)
).slice(0, 64)
: [];
const workflowState = normalizeWorkflowState(raw.workflowState);
let workflowState = normalizeWorkflowState(raw.workflowState);
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
const replacementResultState = normalizeAgentGenerationProjectReplacementResultState(
const initialReplacementResultState = normalizeAgentGenerationProjectReplacementResultState(
raw.replacementResultState
);
const resultCollections = reconcileReplacementResultCollections({
replacementResultState,
let resultCollections = reconcileReplacementResultCollections({
replacementResultState: initialReplacementResultState,
resultCollections: normalizeMigratedResultCollections({
workflowState,
resultImageUrls,
resultCollections: normalizeResultCollections(raw.resultCollections),
}),
});
const replacementResultState = reconcileReplacementResultState({
workflowState,
resultCollections,
replacementResultState: initialReplacementResultState,
});
workflowState = reconcileWorkflowReplacementReferences({
workflowState,
replacementResultState,
});
resultCollections = reconcileReplacementResultCollections({
resultCollections,
replacementResultState,
});
const workspaceState = normalizeWorkspaceState(raw.workspaceState);
if (!hasExplicitWorkspaceBusinessModeKey(raw.workspaceState)) {
workspaceState.activeBusinessModeKey = inferWorkspaceBusinessModeKey({

View File

@ -1,4 +1,5 @@
import {
doesAgentProjectStateCoverReplacementResults,
getAllResultUrlsFromAgentProjectState,
mergeAgentProjectStateForSave,
} from "@/lib/agents/agent-project-save-merge";
@ -9,6 +10,8 @@ const DETAIL_RESULT_URL = "https://scqny.jiansun.vip/uploads/1785470582893-rmh0x
const DETAIL_RESULT_URL_2 = "https://scqny.jiansun.vip/uploads/1785470577924-owj79k.png";
const SKU_RESULT_URL = "https://scqny.jiansun.vip/uploads/1785470458027-c6yuar.png";
const DETAIL_REFERENCE_URL = "https://example.com/detail-reference.png";
const STALE_DETAIL_RESULT_URL = "https://example.com/stale-detail-result.png";
const THIRD_DETAIL_RESULT_URL = "https://example.com/third-detail-result.png";
function createWorkflowState(params?: {
detailReferences?: number;
@ -97,6 +100,14 @@ function expectExcludesUrl(urls: string[] | undefined, url: string) {
expect(!urls?.includes(url), `expected ${url} to be absent`);
}
function simulateDatabaseRoundTrip(
state: AgentGenerationProjectPersistedState
) {
return JSON.parse(
JSON.stringify(state)
) as AgentGenerationProjectPersistedState;
}
const legacyDetailState = createExistingDetailProjectState();
expectEqual(
legacyDetailState.workspaceState?.activeBusinessModeKey,
@ -222,6 +233,272 @@ expectIncludesUrl(
getAllResultUrlsFromAgentProjectState(replacementStateOnlySave),
DETAIL_RESULT_URL_2
);
expectEqual(
replacementStateOnlySave.workflowState.agentModeState?.detailReplacement.references.length,
1,
"replacement-state-only recovered reference count"
);
const collectionOnlyLegacyState = normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "legacy collection-only replacement",
selectedModelId: "auto",
},
resultCollections: {
detail_replacement: [DETAIL_RESULT_URL_2],
},
});
expectEqual(
collectionOnlyLegacyState.replacementResultState?.detail_replacement?.["detail-card-1"]?.url,
DETAIL_RESULT_URL_2,
"collection-only recovered replacement record"
);
const recordOnlyLegacyState = normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState(),
workspaceState: {
input: "legacy record-only replacement",
selectedModelId: "auto",
},
replacementResultState: {
detail_replacement: {
"recovered-detail-card": {
url: DETAIL_RESULT_URL,
text: "completed",
status: "completed",
updatedAt: 4,
referenceImageUrl: DETAIL_REFERENCE_URL,
label: "Recovered detail",
},
},
},
});
expectEqual(
recordOnlyLegacyState.workflowState.agentModeState?.detailReplacement.references[0]?.id,
"recovered-detail-card",
"record-only recovered reference id"
);
expectEqual(
recordOnlyLegacyState.workflowState.agentModeState?.detailReplacement.references[0]?.imageUrl,
DETAIL_REFERENCE_URL,
"record-only recovered reference image"
);
const newerServerRecordState = mergeAgentProjectStateForSave({
existingState: normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "",
selectedModelId: "auto",
},
replacementResultState: {
detail_replacement: {
"detail-card-1": {
url: DETAIL_RESULT_URL_2,
text: "newer server result",
status: "completed",
updatedAt: 20,
},
},
},
}),
incomingState: {
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "",
selectedModelId: "auto",
activeBusinessModeKey: "detail",
},
replacementResultState: {
detail_replacement: {
"detail-card-1": {
url: STALE_DETAIL_RESULT_URL,
text: "stale browser result",
status: "completed",
updatedAt: 10,
},
},
},
},
});
expectEqual(
newerServerRecordState.replacementResultState?.detail_replacement?.["detail-card-1"]?.url,
DETAIL_RESULT_URL_2,
"newer server replacement record"
);
expect(
!doesAgentProjectStateCoverReplacementResults({
candidateState: normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ detailReferences: 1 }),
resultCollections: {
detail_replacement: [STALE_DETAIL_RESULT_URL],
},
}),
authoritativeState: newerServerRecordState,
}),
"stale local draft must not cover newer server replacement results"
);
expect(
doesAgentProjectStateCoverReplacementResults({
candidateState: normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ detailReferences: 1 }),
resultCollections: {
detail_replacement: [DETAIL_RESULT_URL_2, STALE_DETAIL_RESULT_URL],
},
}),
authoritativeState: newerServerRecordState,
}),
"newer local draft should cover all server replacement results"
);
const partialReferenceSaveState = mergeAgentProjectStateForSave({
existingState: normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ detailReferences: 3 }),
workspaceState: {
input: "",
selectedModelId: "auto",
},
replacementResultState: {
detail_replacement: {
"detail-card-1": {
url: DETAIL_RESULT_URL,
text: "completed 1",
status: "completed",
updatedAt: 11,
},
"detail-card-2": {
url: DETAIL_RESULT_URL_2,
text: "completed 2",
status: "completed",
updatedAt: 12,
},
"detail-card-3": {
url: THIRD_DETAIL_RESULT_URL,
text: "completed 3",
status: "completed",
updatedAt: 13,
},
},
},
}),
incomingState: {
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "",
selectedModelId: "auto",
activeBusinessModeKey: "detail",
},
},
});
expectEqual(
partialReferenceSaveState.workflowState.agentModeState?.detailReplacement.references.length,
3,
"partial stale reference save keeps result cards"
);
const newerIncomingRecordState = mergeAgentProjectStateForSave({
existingState: newerServerRecordState,
incomingState: {
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "",
selectedModelId: "auto",
activeBusinessModeKey: "detail",
},
replacementResultState: {
detail_replacement: {
"detail-card-1": {
url: THIRD_DETAIL_RESULT_URL,
text: "newest browser result",
status: "completed",
updatedAt: 30,
},
},
},
},
});
expectEqual(
newerIncomingRecordState.replacementResultState?.detail_replacement?.["detail-card-1"]?.url,
THIRD_DETAIL_RESULT_URL,
"newer incoming replacement record"
);
let repeatedAutosaveState = normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ detailReferences: 39 }),
workspaceState: {
input: "39-card detail replacement",
selectedModelId: "auto",
},
replacementResultState: {
detail_replacement: {
"detail-card-1": {
url: DETAIL_RESULT_URL,
text: "completed 1",
status: "completed",
updatedAt: 101,
},
"detail-card-2": {
url: DETAIL_RESULT_URL_2,
text: "completed 2",
status: "completed",
updatedAt: 102,
},
"detail-card-3": {
url: THIRD_DETAIL_RESULT_URL,
text: "completed 3",
status: "completed",
updatedAt: 103,
},
},
},
});
for (let index = 0; index < 40; index += 1) {
repeatedAutosaveState = simulateDatabaseRoundTrip(
mergeAgentProjectStateForSave({
existingState: simulateDatabaseRoundTrip(repeatedAutosaveState),
incomingState: {
workflowState: createWorkflowState(),
workspaceState: {
input: `stale autosave ${index + 1}`,
selectedModelId: "auto",
activeBusinessModeKey: "main_sku",
},
resultCollections: {},
replacementResultState: {},
},
})
);
}
expectEqual(
repeatedAutosaveState.workspaceState?.activeBusinessModeKey,
"detail",
"repeated autosave detail mode"
);
expectEqual(
repeatedAutosaveState.workflowState.agentModeState?.detailReplacement.references.length,
39,
"repeated autosave reference count"
);
expectIncludesUrl(
repeatedAutosaveState.resultCollections?.detail_replacement,
DETAIL_RESULT_URL
);
expectIncludesUrl(
repeatedAutosaveState.resultCollections?.detail_replacement,
DETAIL_RESULT_URL_2
);
expectIncludesUrl(
repeatedAutosaveState.resultCollections?.detail_replacement,
THIRD_DETAIL_RESULT_URL
);
expectEqual(
JSON.stringify(
normalizeAgentGenerationProjectPersistedState(repeatedAutosaveState)
),
JSON.stringify(repeatedAutosaveState),
"normalized recovery state is idempotent"
);
const deletedResultSave = mergeAgentProjectStateForSave({
existingState: legacyDetailState,
@ -282,6 +559,14 @@ console.log(
"stale empty save keeps detail results",
"stale empty save keeps detail reference cards",
"replacementResultState repopulates resultCollections",
"resultCollections rebuild replacementResultState",
"replacementResultState rebuilds missing reference cards",
"newer replacement records win concurrent saves",
"local draft must cover server replacement URLs",
"partial stale reference saves keep completed cards",
"newer incoming replacement records remain writable",
"39-card repeated stale autosaves preserve completed results",
"normalized recovery state remains idempotent",
"explicit deletion is respected",
"sku replacement recovery",
],