From 8b4fd29fce5f87bc4e03fdcc420cd9de2b5ccfa0 Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 31 Jul 2026 20:05:18 +0800 Subject: [PATCH] fix(agent): harden detail replacement recovery --- .../[id]/CanvasEditorLegacyPageClient.tsx | 35 +-- lib/agents/agent-project-save-merge.ts | 141 +++++++-- lib/agents/generation-project-state.ts | 173 ++++++++++- .../test-agent-detail-replacement-recovery.ts | 285 ++++++++++++++++++ 4 files changed, 583 insertions(+), 51 deletions(-) diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 713d8f327..e2fbd855a 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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) { diff --git a/lib/agents/agent-project-save-merge.ts b/lib/agents/agent-project-save-merge.ts index 4cfd3fa75..afa9b9a22 100644 --- a/lib/agents/agent-project-save-merge.ts +++ b/lib/agents/agent-project-save-merge.ts @@ -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>( + (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, + 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, + }, }, }; } diff --git a/lib/agents/generation-project-state.ts b/lib/agents/generation-project-state.ts index be71b9f55..0d3b613cd 100644 --- a/lib/agents/generation-project-state.ts +++ b/lib/agents/generation-project-state.ts @@ -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({ diff --git a/scripts/test-agent-detail-replacement-recovery.ts b/scripts/test-agent-detail-replacement-recovery.ts index c7cb05328..e0f378aa9 100644 --- a/scripts/test-agent-detail-replacement-recovery.ts +++ b/scripts/test-agent-detail-replacement-recovery.ts @@ -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", ],