fix(agent): harden detail replacement recovery
This commit is contained in:
parent
3c6b7b7778
commit
8b4fd29fce
@ -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) {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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",
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user