test(agent): cover detail replacement recovery

This commit is contained in:
134 2026-07-31 19:43:13 +08:00
parent d5737b70c0
commit 3c6b7b7778
3 changed files with 586 additions and 145 deletions

View File

@ -7,12 +7,10 @@ import {
extractAgentProjectStateFromCanvasData,
isAgentProjectRow,
} from "@/lib/agents/agent-project-compat";
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
import type {
AgentGenerationProjectPersistedState,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections,
} from "@/types/agent";
import {
getAllResultUrlsFromAgentProjectState,
mergeAgentProjectStateForSave,
} from "@/lib/agents/agent-project-save-merge";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@ -30,144 +28,6 @@ function mapAgentProjectRow(row: typeof canvasProject.$inferSelect) {
};
}
const RESULT_COLLECTION_KEYS = [
"scene_generation",
"detail_generation",
"detail_replacement",
"sku_replacement",
] as const;
const REPLACEMENT_RESULT_KEYS = [
"detail_replacement",
"sku_replacement",
] as const;
function normalizeProjectResultUrlList(urls: string[] | null | undefined) {
return Array.from(
new Set(
(urls || [])
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
}
function mergeResultCollections(
existing: AgentGenerationProjectResultCollections | null | undefined,
incoming: AgentGenerationProjectResultCollections | null | undefined
): AgentGenerationProjectResultCollections {
return RESULT_COLLECTION_KEYS.reduce<AgentGenerationProjectResultCollections>(
(next, key) => {
next[key] = normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(existing?.[key]),
...normalizeProjectResultUrlList(incoming?.[key]),
]);
return next;
},
{}
);
}
function getResultCollectionUrlSet(
collections: AgentGenerationProjectResultCollections | null | undefined
) {
return new Set(
RESULT_COLLECTION_KEYS.flatMap((key) => normalizeProjectResultUrlList(collections?.[key]))
);
}
function removeDeletedUrlsFromCollections(
collections: AgentGenerationProjectResultCollections,
deletedUrls: Set<string>
): AgentGenerationProjectResultCollections {
return RESULT_COLLECTION_KEYS.reduce<AgentGenerationProjectResultCollections>(
(next, key) => {
next[key] = normalizeProjectResultUrlList(collections[key]).filter(
(url) => !deletedUrls.has(url)
);
return next;
},
{}
);
}
function mergeReplacementResultState(
existing: AgentGenerationProjectReplacementResultState | null | undefined,
incoming: AgentGenerationProjectReplacementResultState | null | undefined,
deletedUrls: Set<string>
): AgentGenerationProjectReplacementResultState {
return REPLACEMENT_RESULT_KEYS.reduce<AgentGenerationProjectReplacementResultState>(
(next, key) => {
const existingModeState = existing?.[key] || {};
const incomingModeState = incoming?.[key] || {};
const mergedModeState = { ...existingModeState, ...incomingModeState };
next[key] = Object.fromEntries(
Object.entries(mergedModeState).filter(([, record]) => {
const url = String(record?.url || "").trim();
return !url || !deletedUrls.has(url);
})
);
return next;
},
{}
);
}
function getReplacementResultUrls(
state: AgentGenerationProjectReplacementResultState | null | undefined
) {
return REPLACEMENT_RESULT_KEYS.flatMap((key) =>
Object.values(state?.[key] || {})
.map((record) => String(record?.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
);
}
function getAllResultUrlsFromState(state: AgentGenerationProjectPersistedState) {
return normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(state.resultImageUrls),
...RESULT_COLLECTION_KEYS.flatMap((key) =>
normalizeProjectResultUrlList(state.resultCollections?.[key])
),
...getReplacementResultUrls(state.replacementResultState),
]);
}
function mergeAgentProjectStateForSave(params: {
existingState: AgentGenerationProjectPersistedState;
incomingState: unknown;
}) {
const existingState = normalizeAgentGenerationProjectPersistedState(params.existingState);
const incomingState = normalizeAgentGenerationProjectPersistedState(params.incomingState);
const deletedResultCollections = mergeResultCollections(
existingState.deletedResultCollections,
incomingState.deletedResultCollections
);
const deletedUrls = getResultCollectionUrlSet(deletedResultCollections);
const replacementResultState = mergeReplacementResultState(
existingState.replacementResultState,
incomingState.replacementResultState,
deletedUrls
);
const resultCollections = removeDeletedUrlsFromCollections(
mergeResultCollections(existingState.resultCollections, incomingState.resultCollections),
deletedUrls
);
return normalizeAgentGenerationProjectPersistedState({
...incomingState,
resultCollections,
deletedResultCollections,
replacementResultState,
resultImageUrls: normalizeProjectResultUrlList([
...getAllResultUrlsFromState(existingState),
...getAllResultUrlsFromState(incomingState),
...RESULT_COLLECTION_KEYS.flatMap((key) => resultCollections[key] || []),
...getReplacementResultUrls(replacementResultState),
]).filter((url) => !deletedUrls.has(url)),
});
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@ -239,7 +99,7 @@ export async function PUT(
incomingState: body.projectState,
})
: existingProjectState;
const firstResultUrl = getAllResultUrlsFromState(nextProjectState)[0] || null;
const firstResultUrl = getAllResultUrlsFromAgentProjectState(nextProjectState)[0] || null;
const requestedThumbnail =
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined;
const thumbnail =

View File

@ -0,0 +1,289 @@
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
import type {
AgentGenerationProjectPersistedState,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections,
AgentWorkflowBusinessModeKey,
} from "@/types/agent";
const RESULT_COLLECTION_KEYS = [
"scene_generation",
"detail_generation",
"detail_replacement",
"sku_replacement",
] as const;
const REPLACEMENT_RESULT_KEYS = [
"detail_replacement",
"sku_replacement",
] as const;
function createFallbackWorkspaceState() {
return {
input: "",
attachments: [],
selectedModelId: "auto",
activeBusinessModeKey: "main_sku" as const,
imageParams: {
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
},
};
}
function normalizeProjectResultUrlList(urls: string[] | null | undefined) {
return Array.from(
new Set(
(urls || [])
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
}
function mergeResultCollections(
existing: AgentGenerationProjectResultCollections | null | undefined,
incoming: AgentGenerationProjectResultCollections | null | undefined
): AgentGenerationProjectResultCollections {
return RESULT_COLLECTION_KEYS.reduce<AgentGenerationProjectResultCollections>(
(next, key) => {
next[key] = normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(existing?.[key]),
...normalizeProjectResultUrlList(incoming?.[key]),
]);
return next;
},
{}
);
}
function getResultCollectionUrlSet(
collections: AgentGenerationProjectResultCollections | null | undefined
) {
return new Set(
RESULT_COLLECTION_KEYS.flatMap((key) => normalizeProjectResultUrlList(collections?.[key]))
);
}
function removeDeletedUrlsFromCollections(
collections: AgentGenerationProjectResultCollections,
deletedUrls: Set<string>
): AgentGenerationProjectResultCollections {
return RESULT_COLLECTION_KEYS.reduce<AgentGenerationProjectResultCollections>(
(next, key) => {
next[key] = normalizeProjectResultUrlList(collections[key]).filter(
(url) => !deletedUrls.has(url)
);
return next;
},
{}
);
}
function mergeReplacementResultState(
existing: AgentGenerationProjectReplacementResultState | null | undefined,
incoming: AgentGenerationProjectReplacementResultState | null | undefined,
deletedUrls: Set<string>
): AgentGenerationProjectReplacementResultState {
return REPLACEMENT_RESULT_KEYS.reduce<AgentGenerationProjectReplacementResultState>(
(next, key) => {
const existingModeState = existing?.[key] || {};
const incomingModeState = incoming?.[key] || {};
const mergedModeState = { ...existingModeState, ...incomingModeState };
next[key] = Object.fromEntries(
Object.entries(mergedModeState).filter(([, record]) => {
const url = String(record?.url || "").trim();
return !url || !deletedUrls.has(url);
})
);
return next;
},
{}
);
}
function getReplacementResultUrls(
state: AgentGenerationProjectReplacementResultState | null | undefined
) {
return REPLACEMENT_RESULT_KEYS.flatMap((key) =>
Object.values(state?.[key] || {})
.map((record) => String(record?.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
);
}
export function getAllResultUrlsFromAgentProjectState(
state: AgentGenerationProjectPersistedState
) {
return normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(state.resultImageUrls),
...RESULT_COLLECTION_KEYS.flatMap((key) =>
normalizeProjectResultUrlList(state.resultCollections?.[key])
),
...getReplacementResultUrls(state.replacementResultState),
]);
}
function hasDetailModeSignals(state: AgentGenerationProjectPersistedState) {
const detailReferenceCount =
state.workflowState.agentModeState?.detailReplacement?.references?.length || 0;
const skuReferenceCount =
state.workflowState.agentModeState?.skuReplacement?.references?.length || 0;
return (
detailReferenceCount > 0 ||
skuReferenceCount > 0 ||
normalizeProjectResultUrlList(state.resultCollections?.detail_generation).length > 0 ||
normalizeProjectResultUrlList(state.resultCollections?.detail_replacement).length > 0 ||
normalizeProjectResultUrlList(state.resultCollections?.sku_replacement).length > 0 ||
getReplacementResultUrls(state.replacementResultState).length > 0
);
}
function hasDetailReplacementResultSignals(params: {
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
return (
normalizeProjectResultUrlList(params.resultCollections.detail_replacement).length > 0 ||
getReplacementResultUrls({
detail_replacement: params.replacementResultState.detail_replacement,
}).length > 0
);
}
function hasSkuReplacementResultSignals(params: {
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
return (
normalizeProjectResultUrlList(params.resultCollections.sku_replacement).length > 0 ||
getReplacementResultUrls({
sku_replacement: params.replacementResultState.sku_replacement,
}).length > 0
);
}
function mergeWorkflowStateForSave(params: {
existingState: AgentGenerationProjectPersistedState;
incomingState: AgentGenerationProjectPersistedState;
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
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;
if (!shouldKeepDetailReplacementState && !shouldKeepSkuReplacementState) {
return params.incomingState.workflowState;
}
return {
...params.incomingState.workflowState,
agentModeState: {
...incomingAgentModeState,
detailReplacement: shouldKeepDetailReplacementState
? existingAgentModeState?.detailReplacement
: incomingAgentModeState?.detailReplacement,
skuReplacement: shouldKeepSkuReplacementState
? existingAgentModeState?.skuReplacement
: incomingAgentModeState?.skuReplacement,
},
};
}
function hasExplicitIncomingBusinessMode(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const raw = value as Record<string, unknown>;
const workspaceState =
raw.workspaceState && typeof raw.workspaceState === "object" && !Array.isArray(raw.workspaceState)
? (raw.workspaceState as Record<string, unknown>)
: {};
return Boolean(String(workspaceState.activeBusinessModeKey || "").trim());
}
function resolveMergedBusinessModeKey(params: {
existingState: AgentGenerationProjectPersistedState;
incomingState: AgentGenerationProjectPersistedState;
rawIncomingState: unknown;
mergedState: AgentGenerationProjectPersistedState;
}): AgentWorkflowBusinessModeKey {
if (hasDetailModeSignals(params.mergedState)) {
const incomingKey = params.incomingState.workspaceState?.activeBusinessModeKey;
const existingKey = params.existingState.workspaceState?.activeBusinessModeKey;
const incomingHasExplicitKey = hasExplicitIncomingBusinessMode(params.rawIncomingState);
if (!incomingHasExplicitKey || existingKey === "detail" || incomingKey === "main_sku") {
return "detail";
}
}
return params.incomingState.workspaceState?.activeBusinessModeKey ||
params.existingState.workspaceState?.activeBusinessModeKey ||
"main_sku";
}
export function mergeAgentProjectStateForSave(params: {
existingState: AgentGenerationProjectPersistedState;
incomingState: unknown;
}): AgentGenerationProjectPersistedState {
const existingState = normalizeAgentGenerationProjectPersistedState(params.existingState);
const incomingState = normalizeAgentGenerationProjectPersistedState(params.incomingState);
const deletedResultCollections = mergeResultCollections(
existingState.deletedResultCollections,
incomingState.deletedResultCollections
);
const deletedUrls = getResultCollectionUrlSet(deletedResultCollections);
const replacementResultState = mergeReplacementResultState(
existingState.replacementResultState,
incomingState.replacementResultState,
deletedUrls
);
const resultCollections = removeDeletedUrlsFromCollections(
mergeResultCollections(existingState.resultCollections, incomingState.resultCollections),
deletedUrls
);
const workflowState = mergeWorkflowStateForSave({
existingState,
incomingState,
resultCollections,
replacementResultState,
});
const mergedState = normalizeAgentGenerationProjectPersistedState({
...incomingState,
workflowState,
resultCollections,
deletedResultCollections,
replacementResultState,
resultImageUrls: normalizeProjectResultUrlList([
...getAllResultUrlsFromAgentProjectState(existingState),
...getAllResultUrlsFromAgentProjectState(incomingState),
...RESULT_COLLECTION_KEYS.flatMap((key) => resultCollections[key] || []),
...getReplacementResultUrls(replacementResultState),
]).filter((url) => !deletedUrls.has(url)),
});
const workspaceState = mergedState.workspaceState || createFallbackWorkspaceState();
return {
...mergedState,
workspaceState: {
...workspaceState,
activeBusinessModeKey: resolveMergedBusinessModeKey({
existingState,
incomingState,
rawIncomingState: params.incomingState,
mergedState,
}),
},
};
}

View File

@ -0,0 +1,292 @@
import {
getAllResultUrlsFromAgentProjectState,
mergeAgentProjectStateForSave,
} from "@/lib/agents/agent-project-save-merge";
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
import type { AgentGenerationProjectPersistedState } from "@/types/agent";
const DETAIL_RESULT_URL = "https://scqny.jiansun.vip/uploads/1785470582893-rmh0xr.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 DETAIL_REFERENCE_URL = "https://example.com/detail-reference.png";
function createWorkflowState(params?: {
detailReferences?: number;
skuReferences?: number;
}) {
return {
inputs: {
primarySku: "",
secondarySku: "",
brand: "",
store: "",
thirdCategory: "",
fourthCategory: "",
fifthCategory: "",
priceBand: "",
},
selections: {},
currentStage: "mode",
agentModeState: {
detailReplacement: {
query: "766308902517",
references: Array.from({ length: params?.detailReferences || 0 }, (_, index) => ({
id: `detail-card-${index + 1}`,
label: `Detail ${index + 1}`,
imageUrl: DETAIL_REFERENCE_URL,
previewUrl: DETAIL_REFERENCE_URL,
})),
},
skuReplacement: {
query: "sku-query",
references: Array.from({ length: params?.skuReferences || 0 }, (_, index) => ({
id: `sku-card-${index + 1}`,
label: `SKU ${index + 1}`,
imageUrl: DETAIL_REFERENCE_URL,
previewUrl: DETAIL_REFERENCE_URL,
})),
},
},
};
}
function createExistingDetailProjectState(): AgentGenerationProjectPersistedState {
return normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "legacy detail replacement",
selectedModelId: "auto",
},
resultCollections: {
detail_replacement: [DETAIL_RESULT_URL],
},
replacementResultState: {
detail_replacement: {
"detail-card-1": {
url: DETAIL_RESULT_URL,
text: "completed",
status: "completed",
updatedAt: 1,
compareSourceUrl: DETAIL_REFERENCE_URL,
referenceImageUrl: DETAIL_REFERENCE_URL,
referencePreviewUrl: DETAIL_REFERENCE_URL,
label: "Detail 1",
},
},
},
});
}
function expect(condition: boolean, message: string) {
if (!condition) {
throw new Error(message);
}
}
function expectEqual<T>(actual: T, expected: T, label: string) {
if (!Object.is(actual, expected)) {
throw new Error(`${label}: expected ${String(expected)}, received ${String(actual)}`);
}
}
function expectIncludesUrl(urls: string[] | undefined, url: string) {
expect(Boolean(urls?.includes(url)), `expected ${url} to be present`);
}
function expectExcludesUrl(urls: string[] | undefined, url: string) {
expect(!urls?.includes(url), `expected ${url} to be absent`);
}
const legacyDetailState = createExistingDetailProjectState();
expectEqual(
legacyDetailState.workspaceState?.activeBusinessModeKey,
"detail",
"legacy detail mode"
);
expectIncludesUrl(legacyDetailState.resultCollections?.detail_replacement, DETAIL_RESULT_URL);
expectEqual(
legacyDetailState.workflowState.agentModeState?.detailReplacement.references.length,
1,
"legacy detail reference count"
);
const resultOnlyLegacyState = normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState(),
workspaceState: {
input: "legacy result-only detail replacement",
selectedModelId: "auto",
},
resultCollections: {
detail_replacement: [DETAIL_RESULT_URL_2],
},
});
expectEqual(
resultOnlyLegacyState.workspaceState?.activeBusinessModeKey,
"detail",
"result-only detail mode"
);
expectIncludesUrl(
resultOnlyLegacyState.resultCollections?.detail_replacement,
DETAIL_RESULT_URL_2
);
const emptyProjectState = normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState(),
workspaceState: {
input: "",
selectedModelId: "auto",
},
});
expectEqual(
emptyProjectState.workspaceState?.activeBusinessModeKey,
"main_sku",
"empty project mode"
);
const staleEmptySaveState = mergeAgentProjectStateForSave({
existingState: legacyDetailState,
incomingState: {
workflowState: createWorkflowState(),
workspaceState: {
input: "",
selectedModelId: "auto",
activeBusinessModeKey: "main_sku",
imageParams: {
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
},
},
resultImageUrls: [],
resultCollections: {},
replacementResultState: {},
},
});
expectEqual(
staleEmptySaveState.workspaceState?.activeBusinessModeKey,
"detail",
"stale empty save mode"
);
expectIncludesUrl(
staleEmptySaveState.resultCollections?.detail_replacement,
DETAIL_RESULT_URL
);
expectIncludesUrl(getAllResultUrlsFromAgentProjectState(staleEmptySaveState), DETAIL_RESULT_URL);
expectEqual(
staleEmptySaveState.workflowState.agentModeState?.detailReplacement.references.length,
1,
"stale empty save reference count"
);
expectEqual(
staleEmptySaveState.replacementResultState?.detail_replacement?.["detail-card-1"]?.url,
DETAIL_RESULT_URL,
"stale empty save replacement record"
);
const replacementStateOnlySave = mergeAgentProjectStateForSave({
existingState: normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "",
selectedModelId: "auto",
},
}),
incomingState: {
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "",
selectedModelId: "auto",
},
replacementResultState: {
detail_replacement: {
"detail-card-1": {
url: DETAIL_RESULT_URL_2,
text: "completed",
status: "completed",
updatedAt: 2,
},
},
},
},
});
expectEqual(
replacementStateOnlySave.workspaceState?.activeBusinessModeKey,
"detail",
"replacement-state-only mode"
);
expectIncludesUrl(
replacementStateOnlySave.resultCollections?.detail_replacement,
DETAIL_RESULT_URL_2
);
expectIncludesUrl(
getAllResultUrlsFromAgentProjectState(replacementStateOnlySave),
DETAIL_RESULT_URL_2
);
const deletedResultSave = mergeAgentProjectStateForSave({
existingState: legacyDetailState,
incomingState: {
workflowState: createWorkflowState({ detailReferences: 1 }),
workspaceState: {
input: "",
selectedModelId: "auto",
activeBusinessModeKey: "detail",
},
deletedResultCollections: {
detail_replacement: [DETAIL_RESULT_URL],
},
resultCollections: {},
replacementResultState: {},
},
});
expectExcludesUrl(deletedResultSave.resultCollections?.detail_replacement, DETAIL_RESULT_URL);
expectExcludesUrl(getAllResultUrlsFromAgentProjectState(deletedResultSave), DETAIL_RESULT_URL);
expectEqual(
deletedResultSave.replacementResultState?.detail_replacement?.["detail-card-1"],
undefined,
"deleted replacement record"
);
const skuReplacementState = normalizeAgentGenerationProjectPersistedState({
workflowState: createWorkflowState({ skuReferences: 1 }),
workspaceState: {
input: "",
selectedModelId: "auto",
},
replacementResultState: {
sku_replacement: {
"sku-card-1": {
url: SKU_RESULT_URL,
text: "completed",
status: "completed",
updatedAt: 3,
},
},
},
});
expectEqual(
skuReplacementState.workspaceState?.activeBusinessModeKey,
"detail",
"sku replacement mode"
);
expectIncludesUrl(skuReplacementState.resultCollections?.sku_replacement, SKU_RESULT_URL);
console.log(
JSON.stringify(
{
ok: true,
covered: [
"legacy detail mode inference",
"result-only detail mode inference",
"empty project stays main_sku",
"stale empty save keeps detail results",
"stale empty save keeps detail reference cards",
"replacementResultState repopulates resultCollections",
"explicit deletion is respected",
"sku replacement recovery",
],
},
null,
2
)
);