fix(agent): harden detail replacement recovery
This commit is contained in:
parent
3c6b7b7778
commit
8b4fd29fce
@ -143,6 +143,7 @@ import {
|
|||||||
normalizeAgentGenerationProjectPersistedState,
|
normalizeAgentGenerationProjectPersistedState,
|
||||||
normalizeAgentGenerationProjectReplacementResultState,
|
normalizeAgentGenerationProjectReplacementResultState,
|
||||||
} from "@/lib/agents/generation-project-state";
|
} from "@/lib/agents/generation-project-state";
|
||||||
|
import { doesAgentProjectStateCoverReplacementResults } from "@/lib/agents/agent-project-save-merge";
|
||||||
import {
|
import {
|
||||||
createEmptyAgentModeWorkspaceState,
|
createEmptyAgentModeWorkspaceState,
|
||||||
normalizeAgentModeWorkspaceState,
|
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(
|
function hasReplacementResultCollectionMismatch(
|
||||||
state: AgentGenerationProjectPersistedState | undefined | null
|
state: AgentGenerationProjectPersistedState | undefined | null
|
||||||
) {
|
) {
|
||||||
@ -4447,26 +4432,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const serverUpdatedAt =
|
const serverUpdatedAt =
|
||||||
Number(new Date(result.data.updatedAt).getTime()) || 0;
|
Number(new Date(result.data.updatedAt).getTime()) || 0;
|
||||||
const serverReplacementResultCount = getReplacementResultUrlCount(
|
|
||||||
result.data.projectState
|
|
||||||
);
|
|
||||||
const localReplacementResultCount = getReplacementResultUrlCount(
|
|
||||||
localDraft?.projectState
|
|
||||||
);
|
|
||||||
const localDraftMissesServerReplacementResults =
|
const localDraftMissesServerReplacementResults =
|
||||||
serverReplacementResultCount > 0 && localReplacementResultCount === 0;
|
!doesAgentProjectStateCoverReplacementResults({
|
||||||
const localDraftReplacementResultsDifferFromServer =
|
candidateState: localDraft?.projectState,
|
||||||
serverReplacementResultCount > 0 &&
|
authoritativeState: result.data.projectState,
|
||||||
localReplacementResultCount > 0 &&
|
});
|
||||||
localReplacementResultCount !== serverReplacementResultCount;
|
|
||||||
const localDraftHasStaleReplacementCollections =
|
const localDraftHasStaleReplacementCollections =
|
||||||
serverReplacementResultCount > 0 &&
|
!localDraftMissesServerReplacementResults &&
|
||||||
hasReplacementResultCollectionMismatch(localDraft?.projectState);
|
hasReplacementResultCollectionMismatch(localDraft?.projectState);
|
||||||
const shouldUseLocalDraft =
|
const shouldUseLocalDraft =
|
||||||
Boolean(localDraft?.projectState) &&
|
Boolean(localDraft?.projectState) &&
|
||||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
|
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
|
||||||
!localDraftMissesServerReplacementResults &&
|
!localDraftMissesServerReplacementResults &&
|
||||||
!localDraftReplacementResultsDifferFromServer &&
|
|
||||||
!localDraftHasStaleReplacementCollections;
|
!localDraftHasStaleReplacementCollections;
|
||||||
|
|
||||||
if (shouldUseLocalDraft && localDraft?.projectState) {
|
if (shouldUseLocalDraft && localDraft?.projectState) {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
|
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
|
||||||
import type {
|
import type {
|
||||||
|
AgentDetailReplacementReferenceItem,
|
||||||
AgentGenerationProjectPersistedState,
|
AgentGenerationProjectPersistedState,
|
||||||
|
AgentGenerationReplacementResultRecord,
|
||||||
AgentGenerationProjectReplacementResultState,
|
AgentGenerationProjectReplacementResultState,
|
||||||
AgentGenerationProjectResultCollections,
|
AgentGenerationProjectResultCollections,
|
||||||
AgentWorkflowBusinessModeKey,
|
AgentWorkflowBusinessModeKey,
|
||||||
@ -90,7 +92,31 @@ function mergeReplacementResultState(
|
|||||||
(next, key) => {
|
(next, key) => {
|
||||||
const existingModeState = existing?.[key] || {};
|
const existingModeState = existing?.[key] || {};
|
||||||
const incomingModeState = incoming?.[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(
|
next[key] = Object.fromEntries(
|
||||||
Object.entries(mergedModeState).filter(([, record]) => {
|
Object.entries(mergedModeState).filter(([, record]) => {
|
||||||
const url = String(record?.url || "").trim();
|
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(
|
export function getAllResultUrlsFromAgentProjectState(
|
||||||
state: AgentGenerationProjectPersistedState
|
state: AgentGenerationProjectPersistedState
|
||||||
) {
|
) {
|
||||||
@ -172,29 +225,81 @@ function mergeWorkflowStateForSave(params: {
|
|||||||
}) {
|
}) {
|
||||||
const incomingAgentModeState = params.incomingState.workflowState.agentModeState;
|
const incomingAgentModeState = params.incomingState.workflowState.agentModeState;
|
||||||
const existingAgentModeState = params.existingState.workflowState.agentModeState;
|
const existingAgentModeState = params.existingState.workflowState.agentModeState;
|
||||||
const shouldKeepDetailReplacementState =
|
const mergeProtectedReferences = (
|
||||||
hasDetailReplacementResultSignals(params) &&
|
existingReferences: AgentDetailReplacementReferenceItem[],
|
||||||
(existingAgentModeState?.detailReplacement?.references?.length || 0) > 0 &&
|
incomingReferences: AgentDetailReplacementReferenceItem[],
|
||||||
(incomingAgentModeState?.detailReplacement?.references?.length || 0) === 0;
|
resultRecords: Record<string, AgentGenerationReplacementResultRecord>,
|
||||||
const shouldKeepSkuReplacementState =
|
hasResultSignals: boolean
|
||||||
hasSkuReplacementResultSignals(params) &&
|
) => {
|
||||||
(existingAgentModeState?.skuReplacement?.references?.length || 0) > 0 &&
|
if (!hasResultSignals || existingReferences.length === 0) {
|
||||||
(incomingAgentModeState?.skuReplacement?.references?.length || 0) === 0;
|
return incomingReferences;
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldKeepDetailReplacementState && !shouldKeepSkuReplacementState) {
|
const protectedCardIds = new Set(
|
||||||
return params.incomingState.workflowState;
|
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 {
|
return {
|
||||||
...params.incomingState.workflowState,
|
...params.incomingState.workflowState,
|
||||||
agentModeState: {
|
agentModeState: {
|
||||||
...incomingAgentModeState,
|
...incomingAgentModeState,
|
||||||
detailReplacement: shouldKeepDetailReplacementState
|
detailReplacement: {
|
||||||
? existingAgentModeState?.detailReplacement
|
...incomingAgentModeState?.detailReplacement,
|
||||||
: incomingAgentModeState?.detailReplacement,
|
references: detailReferences,
|
||||||
skuReplacement: shouldKeepSkuReplacementState
|
},
|
||||||
? existingAgentModeState?.skuReplacement
|
skuReplacement: {
|
||||||
: incomingAgentModeState?.skuReplacement,
|
...incomingAgentModeState?.skuReplacement,
|
||||||
|
references: skuReferences,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type {
|
|||||||
AgentGenerationResultModeType,
|
AgentGenerationResultModeType,
|
||||||
AgentGenerationReplacementResultStatus,
|
AgentGenerationReplacementResultStatus,
|
||||||
AgentGenerationProjectWorkspaceState,
|
AgentGenerationProjectWorkspaceState,
|
||||||
|
AgentDetailReplacementReferenceItem,
|
||||||
AgentWorkflowBusinessModeKey,
|
AgentWorkflowBusinessModeKey,
|
||||||
AgentWorkflowPersistedState,
|
AgentWorkflowPersistedState,
|
||||||
} from "@/types/agent";
|
} from "@/types/agent";
|
||||||
@ -214,6 +215,157 @@ function reconcileReplacementResultCollections(params: {
|
|||||||
} satisfies AgentGenerationProjectResultCollections;
|
} 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(
|
function inferLegacyResultModeType(
|
||||||
workflowState: AgentWorkflowPersistedState,
|
workflowState: AgentWorkflowPersistedState,
|
||||||
resultUrls: string[]
|
resultUrls: string[]
|
||||||
@ -452,19 +604,32 @@ export function normalizeAgentGenerationProjectPersistedState(
|
|||||||
)
|
)
|
||||||
).slice(0, 64)
|
).slice(0, 64)
|
||||||
: [];
|
: [];
|
||||||
const workflowState = normalizeWorkflowState(raw.workflowState);
|
let workflowState = normalizeWorkflowState(raw.workflowState);
|
||||||
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
|
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
|
||||||
const replacementResultState = normalizeAgentGenerationProjectReplacementResultState(
|
const initialReplacementResultState = normalizeAgentGenerationProjectReplacementResultState(
|
||||||
raw.replacementResultState
|
raw.replacementResultState
|
||||||
);
|
);
|
||||||
const resultCollections = reconcileReplacementResultCollections({
|
let resultCollections = reconcileReplacementResultCollections({
|
||||||
replacementResultState,
|
replacementResultState: initialReplacementResultState,
|
||||||
resultCollections: normalizeMigratedResultCollections({
|
resultCollections: normalizeMigratedResultCollections({
|
||||||
workflowState,
|
workflowState,
|
||||||
resultImageUrls,
|
resultImageUrls,
|
||||||
resultCollections: normalizeResultCollections(raw.resultCollections),
|
resultCollections: normalizeResultCollections(raw.resultCollections),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
const replacementResultState = reconcileReplacementResultState({
|
||||||
|
workflowState,
|
||||||
|
resultCollections,
|
||||||
|
replacementResultState: initialReplacementResultState,
|
||||||
|
});
|
||||||
|
workflowState = reconcileWorkflowReplacementReferences({
|
||||||
|
workflowState,
|
||||||
|
replacementResultState,
|
||||||
|
});
|
||||||
|
resultCollections = reconcileReplacementResultCollections({
|
||||||
|
resultCollections,
|
||||||
|
replacementResultState,
|
||||||
|
});
|
||||||
const workspaceState = normalizeWorkspaceState(raw.workspaceState);
|
const workspaceState = normalizeWorkspaceState(raw.workspaceState);
|
||||||
if (!hasExplicitWorkspaceBusinessModeKey(raw.workspaceState)) {
|
if (!hasExplicitWorkspaceBusinessModeKey(raw.workspaceState)) {
|
||||||
workspaceState.activeBusinessModeKey = inferWorkspaceBusinessModeKey({
|
workspaceState.activeBusinessModeKey = inferWorkspaceBusinessModeKey({
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
doesAgentProjectStateCoverReplacementResults,
|
||||||
getAllResultUrlsFromAgentProjectState,
|
getAllResultUrlsFromAgentProjectState,
|
||||||
mergeAgentProjectStateForSave,
|
mergeAgentProjectStateForSave,
|
||||||
} from "@/lib/agents/agent-project-save-merge";
|
} 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 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 SKU_RESULT_URL = "https://scqny.jiansun.vip/uploads/1785470458027-c6yuar.png";
|
||||||
const DETAIL_REFERENCE_URL = "https://example.com/detail-reference.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?: {
|
function createWorkflowState(params?: {
|
||||||
detailReferences?: number;
|
detailReferences?: number;
|
||||||
@ -97,6 +100,14 @@ function expectExcludesUrl(urls: string[] | undefined, url: string) {
|
|||||||
expect(!urls?.includes(url), `expected ${url} to be absent`);
|
expect(!urls?.includes(url), `expected ${url} to be absent`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function simulateDatabaseRoundTrip(
|
||||||
|
state: AgentGenerationProjectPersistedState
|
||||||
|
) {
|
||||||
|
return JSON.parse(
|
||||||
|
JSON.stringify(state)
|
||||||
|
) as AgentGenerationProjectPersistedState;
|
||||||
|
}
|
||||||
|
|
||||||
const legacyDetailState = createExistingDetailProjectState();
|
const legacyDetailState = createExistingDetailProjectState();
|
||||||
expectEqual(
|
expectEqual(
|
||||||
legacyDetailState.workspaceState?.activeBusinessModeKey,
|
legacyDetailState.workspaceState?.activeBusinessModeKey,
|
||||||
@ -222,6 +233,272 @@ expectIncludesUrl(
|
|||||||
getAllResultUrlsFromAgentProjectState(replacementStateOnlySave),
|
getAllResultUrlsFromAgentProjectState(replacementStateOnlySave),
|
||||||
DETAIL_RESULT_URL_2
|
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({
|
const deletedResultSave = mergeAgentProjectStateForSave({
|
||||||
existingState: legacyDetailState,
|
existingState: legacyDetailState,
|
||||||
@ -282,6 +559,14 @@ console.log(
|
|||||||
"stale empty save keeps detail results",
|
"stale empty save keeps detail results",
|
||||||
"stale empty save keeps detail reference cards",
|
"stale empty save keeps detail reference cards",
|
||||||
"replacementResultState repopulates resultCollections",
|
"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",
|
"explicit deletion is respected",
|
||||||
"sku replacement recovery",
|
"sku replacement recovery",
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user