import { expect, test } from "@playwright/test";
import {
collectMessageImageUrls,
extractAssistantImageUrls,
extractAudioUrls,
extractVideoUrls,
filterStandaloneAssistantImageUrls,
getMarkdownTableImageUrlKeys,
normalizeMediaUrl,
sortCanvasMessagesByCreatedAt,
stripMediaMarkup,
} from "@/components/canvas/chat-panel/canvas-chat-media";
import {
filterCanvasMediaPayloadForUnhandledItems,
buildAgentProgressNodeSteps,
getCanvasMediaPayloadHandledItemIds,
getCanvasOperationHandledMediaItemIds,
getAgentProgressStepLabel,
isCanvasAgentViewOperationClientEnabled,
normalizeCanvasAgentClientBoolean,
shouldRetryCanvasViewOperationAfterRefresh,
shouldSkipCanvasMediaPayloadForHandledItems,
} from "@/components/canvas/chat-panel/canvas-agent-stream";
import {
mergePendingCanvasAgentTargetNodeIds,
resolvePendingCanvasAgentTargetNode,
} from "@/components/canvas/chat-panel/canvas-agent-targets";
import {
consumeCanvasAgentAnnotationEditRequest,
consumeCanvasAgentSlotFillRequest,
getPendingCanvasAgentAnnotationEditRequest,
getPendingCanvasAgentSlotFillRequest,
storeCanvasAgentAnnotationEditRequest,
storeCanvasAgentSlotFillRequest,
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
import {
normalizeCanvasMediaStreamPayload,
writeCanvasMediaChunk,
writeCanvasOperationChunk,
} from "@/app/(chat)/api/chat/_lib/stream";
import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context";
import {
buildCanvasAgentAnnotationEditPrompt,
hasCanvasAgentAnnotationEditIntent,
} from "@/lib/canvas/agent-annotation-prompt";
import {
defaultCanvasToolbarConfig,
getCanvasToolbarItem,
} from "@/lib/canvas-toolbar-config";
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
import {
inferCanvasAgentImageEditCapability,
resolveCanvasAgentRequestPolicy,
resolveCanvasAgentTargetPreference,
} from "@/lib/ai/runtime/canvas-agent-kernel";
import { isExplicitImageGenerationRequest } from "@/lib/ai/smart-analyzer";
import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent";
import {
buildAgentPlanningBriefMarkdown,
extractAgentTaskGroups,
isAgentPlanningBriefMarkdown,
} from "@/lib/ai/runtime/agent-planning-brief";
import {
buildRecentDialogueMemory,
isRecentDialogueRecallRequest,
} from "@/lib/ai/runtime/recent-dialogue-memory";
import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text";
import {
isAgentModeScopedChatId,
isScopedEmptyHistoryRequest,
isValidMessagesChatId,
normalizeMessagesChatId,
} from "@/lib/chat/messages-route-access";
import {
CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE,
CANVAS_NODE_NOTE_ANNOTATION_SOURCE,
CANVAS_VISUAL_ANNOTATION_SOURCE,
appendCanvasVisualAnnotationsMetadata,
syncCanvasLocalEditRegionAnnotationsMetadata,
syncCanvasNodeNoteAnnotationMetadata,
} from "@/lib/canvas/agent-annotation-metadata";
import {
buildPendingCanvasOperation,
extractPendingCanvasOperationFromWorkingState,
isCanvasOperationCancelText,
isCanvasOperationConfirmText,
withConfirmedCanvasOperationPlan,
} from "@/lib/canvas/agent-confirmation";
import { shouldRejectStaleCanvasAgentHighRiskMutation } from "@/lib/canvas/agent-risk";
import {
buildCanvasAgentSlotCreationPlan,
normalizeCanvasAgentModelPlannerDecision,
resolveCanvasAgentSlotCreationIntent,
shouldConsiderCanvasAgentModelPlanner,
} from "@/lib/canvas/agent-model-planner";
import { buildCanvasAgentExplicitOperationPlan } from "@/lib/canvas/agent-explicit-operations";
import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback";
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
import { buildCanvasAgentResultViewOperation } from "@/lib/canvas/agent-result-view";
import {
buildCanvasAgentComparisonViewPlan,
buildCanvasAgentLatestResultViewPlan,
hasCanvasAgentComparisonViewIntent,
hasCanvasAgentLatestResultViewIntent,
resolveCanvasAgentComparisonNodeIds,
} from "@/lib/canvas/agent-view-intent";
import {
buildCanvasAgentPanViewPlan,
hasCanvasAgentPanViewIntent,
} from "@/lib/canvas/agent-view-pan";
import {
computeCanvasAgentPanViewport,
resolveCanvasViewOperation,
resolveCanvasViewOperationTargetNodeIds,
} from "@/lib/canvas/agent-view-operations";
import {
easeOutCanvasAgentViewProgress,
interpolateCanvasAgentViewport,
normalizeCanvasAgentViewAnimationDuration,
} from "@/lib/canvas/agent-view-animation";
import {
CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS,
shouldDeferCanvasAgentViewOperation,
} from "@/lib/canvas/agent-view-interaction-guard";
import {
getCanvasAgentOperationErrorMessage,
normalizeCanvasAgentOperationErrorCode,
} from "@/lib/canvas/agent-operation-errors";
import {
buildStaleCanvasSaveConflictPayload,
isStaleCanvasSaveBaseRevision,
parseCanvasRevisionDate,
} from "@/lib/canvas/canvas-save-conflict";
import {
buildCanvasSaveRequestBody,
mergeCanvasSaveRevisionOptions,
} from "@/lib/canvas/canvas-save-request";
import { canvasService } from "@/services/canvas";
import {
hasCanvasAgentSlotFillContextIntent,
hasCanvasAgentNonGenerativeControlIntent,
shouldPreserveCanvasAgentSlotOnlyContext,
shouldBypassCanvasImagePointsPrecheckForAgentControl,
} from "@/lib/canvas/agent-client-intent";
import {
getDirectAgentToolDisplayName,
getDirectAgentToolLabel,
getDirectAgentToolPrefaceFallback,
getDirectAgentToolSuccessMessage,
hasExplicitDirectAgentBatchImageEditIntent,
isDirectAgentBatchImageEditCapability,
resolveDirectAgentToolContext,
resolveDirectAgentToolExecution,
shouldRunDirectAgentBatchImageEdit,
} from "@/lib/ai/runtime/direct-agent-execution";
import {
buildDirectAgentImageEditAssistantText,
buildDirectAgentImageGenerationAssistantText,
} from "@/lib/ai/runtime/direct-agent-response-builders";
import { inferAngleEditOptionsFromText } from "@/lib/ai/runtime/angle-edit-options";
import {
hasDirectAgentLocalEditIntent,
normalizeDirectAgentLocalEditRect,
resolveDirectAgentLocalEditRegions,
} from "@/lib/ai/runtime/local-edit-options";
import {
applyCanvasOperationPlanToCanvasData,
CanvasAgentMutationError,
} from "@/lib/canvas/agent-mutations";
import {
appendCanvasAgentOperationAuditIfMissing,
buildIdempotentCanvasAgentAuditResult,
findCanvasAgentOperationAudit,
} from "@/lib/canvas/agent-operation-audit";
import {
CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY,
CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY,
CANVAS_AGENT_SLOT_ENABLED_KEY,
CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY,
CANVAS_AGENT_WRITEBACK_MODE_KEY,
canvasAgentFeatureFlagsToSettings,
canvasAgentFeatureSettingsToSystemSettings,
canApplyCanvasAgentMutations,
getCanvasAgentFeatureFlags,
normalizeCanvasAgentFeatureSettings,
normalizeCanvasAgentBoolean,
normalizeCanvasAgentWritebackMode,
shouldStreamCanvasAgentShadowOperations,
} from "@/lib/canvas/agent-feature-flags";
import { useCanvasStore } from "@/store/canvas";
import {
buildDetailResultMarkdown,
buildDetailReviewAppendMarkdown,
buildDetailReviewPlans,
} from "@/components/canvas/chat-panel/canvas-detail-markdown";
import {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
isOpenClawSpreadsheetFileName,
rowsToOpenClawMarkdownTable,
} from "@/lib/openclaw-chat/spreadsheet-markdown";
import {
DETAIL_BUNDLE_SLOT_INDEXES,
DETAIL_GENERATION_MODE_OPTIONS,
DETAIL_MODEL_SLOT_INDEX,
DETAIL_PRODUCT_SLOT_INDEXES,
DETAIL_SLOT_CONFIGS,
DETAIL_TARGET_HEIGHT,
DETAIL_TARGET_WIDTH,
extractDetailFrameCount,
extractFirstNumber,
extractFontWeight,
normalizeDetailGenerationMode,
sanitizeDetailImageModelPrompt,
splitDetailKeywords,
} from "@/components/canvas/chat-panel/canvas-detail-config";
import {
createDetailArchiveSnapshot,
patchDetailArchiveList,
prependDetailArchive,
replaceSavedDetailArchive,
sanitizeDetailArchiveList,
} from "@/components/canvas/chat-panel/canvas-detail-archive";
import {
applyDetailSlotAttachment,
fillDetailSlotAttachments,
getDetailSlotTargetIndexes,
} from "@/components/canvas/chat-panel/canvas-detail-slots";
import {
buildCanvasUploadedImageAttachment,
CANVAS_IMAGE_UPLOAD_MAX_BYTES,
getCanvasUploadImageTitle,
normalizeUploadedAssetId,
validateCanvasImageUploadFile,
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
import type { ChatMessage } from "@/lib/types";
import type { DBMessage } from "@/lib/db/schema";
function dbTextMessage(
id: string,
role: "user" | "assistant",
text: string,
): DBMessage {
return {
id,
role,
parts: [{ type: "text", text }],
} as unknown as DBMessage;
}
test.describe("canvas chat pure helpers", () => {
test("keeps optimistic messages without timestamps after persisted history", () => {
const messages = [
{
id: "optimistic-latest-user",
role: "user",
parts: [{ type: "text", text: "你能为我做什么?列出表给我" }],
},
{
id: "persisted-first-user",
role: "user",
createdAt: "2026-07-07T01:00:00.000Z",
parts: [{ type: "text", text: "你好" }],
},
{
id: "persisted-first-assistant",
role: "assistant",
createdAt: "2026-07-07T01:00:01.000Z",
parts: [{ type: "text", text: "你好,我在。" }],
},
] as ChatMessage[];
expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([
"persisted-first-user",
"persisted-first-assistant",
"optimistic-latest-user",
]);
});
test("preserves relative order among messages without timestamps", () => {
const messages = [
{
id: "pending-user",
role: "user",
parts: [{ type: "text", text: "生成一张图" }],
},
{
id: "pending-assistant",
role: "assistant",
parts: [{ type: "text", text: "分析中" }],
},
] as ChatMessage[];
expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([
"pending-user",
"pending-assistant",
]);
});
test("formats uploaded spreadsheet rows as safe markdown tables for OpenClaw", () => {
const markdown = rowsToOpenClawMarkdownTable([
["名称", "备注"],
["A|B", "第一行\n第二行"],
["空值列", ""],
]);
expect(markdown).toContain("| 名称 | 备注 |");
expect(markdown).toContain("A\\|B");
expect(markdown).toContain("第一行
第二行");
expect(isOpenClawSpreadsheetFileName("sample.xlsx")).toBe(true);
expect(isOpenClawSpreadsheetFileName("sample.xls")).toBe(false);
});
test("keeps OpenClaw spreadsheet display text compact while upstream message includes markdown", () => {
const files = [
{
fileName: "sales.csv",
rowCount: 12,
sheetCount: 1,
truncated: false,
},
];
const display = buildOpenClawDisplayMessage({ message: "", files });
expect(display).toContain("请分析上传的表格内容");
expect(display).toContain("sales.csv");
expect(display).not.toContain("| SKU |");
const upstream = buildOpenClawMessageWithSpreadsheetMarkdown({
message: "分析销量",
spreadsheetMarkdown: "| SKU | 销量 |\n| --- | --- |\n| A01 | 20 |",
});
expect(upstream).toContain("分析销量");
expect(upstream).toContain("以下是用户上传表格转换后的 Markdown 内容");
expect(upstream).toContain("| SKU | 销量 |");
});
test("recognizes canvas product capability questions with typo tolerance", () => {
expect(isCapabilityQuestionTurnRequest("你能为我做什么?")).toBe(true);
expect(isCapabilityQuestionTurnRequest("你嫩为我做什么?")).toBe(true);
expect(isCapabilityQuestionTurnRequest("你有哪些功能")).toBe(true);
expect(isCapabilityQuestionTurnRequest("帮我把这张图换成白底")).toBe(false);
});
test("builds bounded recent dialogue memory for user recall requests", () => {
const memory = buildRecentDialogueMemory({
messages: [
dbTextMessage("m1", "user", "你好"),
dbTextMessage("m2", "assistant", "你好,我在。"),
dbTextMessage("m3", "user", "你是一个小可爱"),
dbTextMessage("m4", "assistant", "嘿嘿,谢谢你的夸奖。"),
],
currentText: "我前面说了什么?",
});
expect(isRecentDialogueRecallRequest("我前面说了什么?")).toBe(true);
expect(memory.isRecallRequest).toBe(true);
expect(memory.entries.map((entry) => entry.role)).toEqual(["user", "user"]);
expect(memory.entries.at(-1)?.content).toBe("你是一个小可爱");
expect(memory.contextSummary).toContain("用户: 你是一个小可爱");
expect(memory.memoryHints.join(" ")).toContain("不要声称没有上下文");
});
test("sanitizes media markup from recent dialogue memory", () => {
const memory = buildRecentDialogueMemory({
messages: [
dbTextMessage("m1", "user", "生成一只小猫"),
dbTextMessage(
"m2",
"assistant",
"\n\n已完成本次生成"
),
],
currentText: "你刚才回复了什么?",
});
expect(memory.entries).toHaveLength(1);
expect(memory.entries[0].role).toBe("assistant");
expect(memory.entries[0].content).toBe("已完成本次生成");
expect(memory.contextSummary).not.toContain("https://example.com");
expect(memory.contextSummary).not.toContain("IMAGE_GENERATION_START");
});
test("infers explicit agent image counts from user text", () => {
expect(inferAgentImageCountFromText("再出三版")).toBe(3);
expect(inferAgentImageCountFromText("基于这张图再来 3 张")).toBe(3);
expect(inferAgentImageCountFromText("生成四张主图")).toBe(4);
expect(inferAgentImageCountFromText("多来两张")).toBe(2);
expect(inferAgentImageCountFromText("生成16:9横版海报")).toBeNull();
expect(inferAgentImageCountFromText("提升到2K清晰度")).toBeNull();
expect(inferAgentImageCountFromText("再出30张", 12)).toBe(12);
});
test("builds market-grade planning brief for grouped image generation requests", () => {
const request =
"基于上传的潮玩角色图片,生成3组共12张图片,要求保持角色核心特征一致。第一组为4张动作表情夸张的半身表情包;第二组为4张不同场景的角色活动照;第三组为4张服装发型差异巨大的造型照,所有图片比例保持3:4。";
const groups = extractAgentTaskGroups(request);
expect(groups).toHaveLength(3);
expect(groups.map((group) => group.count)).toEqual([4, 4, 4]);
expect(groups[0].description).toContain("半身表情包");
expect(groups[1].description).toContain("不同场景");
expect(groups[2].description).toContain("服装发型");
const markdown = buildAgentPlanningBriefMarkdown({
userText: request,
intent: "image_gen",
nextAction: "generate_image",
preferredCapability: "generate_image",
targetSource: "current_upload",
hasImage: true,
imageCount: 12,
aspectRatio: "3:4",
imageSize: "2K",
});
expect(isAgentPlanningBriefMarkdown(markdown)).toBe(true);
expect(markdown).toContain("### 意图分析");
expect(markdown).toContain("拆成 3 组,共 12 张");
expect(markdown).toContain("### 任务规划");
expect(markdown).toContain("第1组:生成4张");
expect(markdown).toContain("第2组:生成4张");
expect(markdown).toContain("第3组:生成4张");
expect(markdown).toContain("图片比例:3:4");
});
test("keeps canvas message empty-history access scoped and explicit", () => {
const canvasChatId = "11111111-1111-4111-8111-111111111111";
const agentScopedChatId = "11111111-1111-4111-8111-a93e17a60001";
expect(normalizeMessagesChatId(`canvas-${canvasChatId}`)).toBe(
canvasChatId
);
expect(normalizeMessagesChatId("canvas-not-a-uuid")).toBe(
"canvas-not-a-uuid"
);
expect(isValidMessagesChatId(canvasChatId)).toBe(true);
expect(isValidMessagesChatId("canvas-not-a-uuid")).toBe(false);
expect(isScopedEmptyHistoryRequest({ chatId: canvasChatId })).toBe(false);
expect(
isScopedEmptyHistoryRequest({ chatId: canvasChatId, scope: "canvas" })
).toBe(true);
expect(
isScopedEmptyHistoryRequest({ chatId: canvasChatId, scope: " AGENT " })
).toBe(true);
expect(
isScopedEmptyHistoryRequest({
chatId: "canvas-not-a-uuid",
scope: "canvas",
})
).toBe(false);
expect(isAgentModeScopedChatId(agentScopedChatId)).toBe(true);
expect(isAgentModeScopedChatId(canvasChatId)).toBe(false);
});
test("normalizes client-side canvas agent feature flags", () => {
expect(normalizeCanvasAgentClientBoolean(undefined, true)).toBe(true);
expect(normalizeCanvasAgentClientBoolean("", false)).toBe(false);
expect(normalizeCanvasAgentClientBoolean("enabled", false)).toBe(true);
expect(normalizeCanvasAgentClientBoolean("off", true)).toBe(false);
expect(normalizeCanvasAgentClientBoolean("disabled", true)).toBe(false);
expect(normalizeCanvasAgentClientBoolean("unexpected", true)).toBe(true);
expect(isCanvasAgentViewOperationClientEnabled(undefined)).toBe(true);
expect(isCanvasAgentViewOperationClientEnabled("false")).toBe(false);
expect(isCanvasAgentViewOperationClientEnabled("1")).toBe(true);
});
test("bypasses image point precheck only for non-generative canvas agent controls", () => {
expect(hasCanvasAgentNonGenerativeControlIntent("把画布放大一点")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("缩小看全局")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("聚焦刚生成结果")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("对比原图和新图")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("新建一个图片槽位")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("创建一个图片槽位")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("删除选中的节点")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("把选中图片背景换白")).toBe(false);
expect(hasCanvasAgentNonGenerativeControlIntent("生成一张产品主图")).toBe(false);
expect(hasCanvasAgentNonGenerativeControlIntent("抠图去背景")).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "把画布放大一点",
activeComposerMode: "image",
agentEnabled: true,
})
).toBe(true);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "聚焦刚生成结果",
activeComposerMode: "image",
agentEnabled: true,
})
).toBe(true);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "对比原图和新图",
activeComposerMode: "image",
agentEnabled: true,
})
).toBe(true);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "把画布放大一点",
activeComposerMode: "image",
attachmentCount: 1,
agentEnabled: true,
})
).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "把画布放大一点",
activeComposerMode: "image",
hasWorkflowContext: true,
agentEnabled: true,
})
).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "把画布放大一点",
activeComposerMode: "image",
agentEnabled: false,
})
).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "生成一张产品主图",
activeComposerMode: "image",
agentEnabled: true,
})
).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "任意 agent 模式文本",
activeComposerMode: "agent",
agentEnabled: true,
})
).toBe(true);
});
test("preserves selected slot-only canvas context for fill and generation intents", () => {
expect(hasCanvasAgentSlotFillContextIntent("在这里生成一张主图")).toBe(true);
expect(hasCanvasAgentSlotFillContextIntent("填充这个槽位")).toBe(true);
expect(hasCanvasAgentSlotFillContextIntent("普通聊天")).toBe(false);
expect(
shouldPreserveCanvasAgentSlotOnlyContext({
text: "在这里生成一张主图",
activeComposerMode: "image",
selectedAgentSlotCount: 1,
agentEnabled: true,
})
).toBe(true);
expect(
shouldPreserveCanvasAgentSlotOnlyContext({
text: "生成一张主图",
activeComposerMode: "image",
selectedAgentSlotCount: 0,
agentEnabled: true,
})
).toBe(false);
expect(
shouldPreserveCanvasAgentSlotOnlyContext({
text: "在这里生成一张主图",
activeComposerMode: "image",
selectedAgentSlotCount: 1,
attachmentCount: 1,
agentEnabled: true,
})
).toBe(false);
expect(
shouldPreserveCanvasAgentSlotOnlyContext({
text: "任意 agent 模式文本",
activeComposerMode: "agent",
selectedAgentSlotCount: 1,
agentEnabled: false,
})
).toBe(true);
});
test("rejects stale autosave bases without blocking fresh or missing revisions", () => {
const currentUpdatedAt = new Date("2026-06-22T00:00:05.500Z");
expect(parseCanvasRevisionDate("not-a-date")).toBeNull();
expect(
isStaleCanvasSaveBaseRevision({
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
currentUpdatedAt,
})
).toBe(true);
expect(
isStaleCanvasSaveBaseRevision({
baseCanvasUpdatedAt: "2026-06-22T00:00:05.000Z",
currentUpdatedAt,
})
).toBe(true);
expect(
isStaleCanvasSaveBaseRevision({
baseCanvasUpdatedAt: "2026-06-22T00:00:05.500Z",
currentUpdatedAt,
})
).toBe(false);
expect(
isStaleCanvasSaveBaseRevision({
currentUpdatedAt,
})
).toBe(false);
expect(buildStaleCanvasSaveConflictPayload(currentUpdatedAt)).toMatchObject({
success: false,
code: "STALE_CANVAS_REVISION",
canvasUpdatedAt: "2026-06-22T00:00:05.500Z",
});
});
test("builds canvas save requests with fallback revision guards", () => {
const canvasData = {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select" as const,
};
expect(
mergeCanvasSaveRevisionOptions({
fallbackOptions: {
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
},
})
).toEqual({
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
});
expect(
mergeCanvasSaveRevisionOptions({
options: {
baseCanvasRevision: "2026-06-22T00:00:02.000Z",
},
fallbackOptions: {
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
},
})
).toEqual({
baseCanvasRevision: "2026-06-22T00:00:02.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
});
expect(
buildCanvasSaveRequestBody({
canvasData,
workspaceType: "video",
fallbackOptions: {
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
},
})
).toMatchObject({
canvasData,
workspaceType: "video",
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
});
});
test("canvas service save uses store revision fallback when options are omitted", async () => {
const canvasData = {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select" as const,
};
const originalFetch = globalThis.fetch;
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const originalWindow = (globalThis as typeof globalThis & { window?: unknown })
.window;
const bodies: Array> = [];
useCanvasStore
.getState()
.setCanvasRevision(
"2026-06-22T00:00:10.000Z",
"2026-06-22T00:00:10.000Z"
);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {},
writable: true,
});
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body || "{}")));
return {
ok: true,
status: 200,
json: async () => ({
success: true,
data: { updatedAt: "2026-06-22T00:00:11.000Z" },
}),
} as Response;
}) as typeof fetch;
try {
await canvasService.saveCanvasState(
"11111111-1111-4111-8111-111111111111",
canvasData
);
await canvasService.saveCanvasState(
"11111111-1111-4111-8111-111111111111",
canvasData,
"canvas",
{
baseCanvasRevision: "2026-06-22T00:00:12.000Z",
}
);
} finally {
globalThis.fetch = originalFetch;
if (hadWindow) {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
writable: true,
});
} else {
Reflect.deleteProperty(globalThis, "window");
}
useCanvasStore.getState().setCanvasRevision(null, null);
}
expect(bodies).toHaveLength(2);
expect(bodies[0]).toMatchObject({
canvasData,
baseCanvasRevision: "2026-06-22T00:00:10.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:10.000Z",
});
expect(bodies[1]).toMatchObject({
canvasData,
workspaceType: "canvas",
baseCanvasRevision: "2026-06-22T00:00:12.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:11.000Z",
});
});
test("canvas service keeps store revision aligned after project load and save", async () => {
const canvasData = {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select" as const,
};
const originalFetch = globalThis.fetch;
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const originalWindow = (globalThis as typeof globalThis & { window?: unknown })
.window;
const responses = [
{
success: true,
data: {
id: "11111111-1111-4111-8111-111111111111",
title: "测试画布",
canvasData,
updatedAt: "2026-06-22T00:01:00.000Z",
},
},
{
success: true,
data: {
updatedAt: "2026-06-22T00:01:05.000Z",
},
},
];
useCanvasStore.getState().setCanvasRevision(null, null);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {},
writable: true,
});
globalThis.fetch = (async () =>
({
ok: true,
status: 200,
json: async () => responses.shift()!,
}) as Response) as typeof fetch;
try {
await canvasService.getProject("11111111-1111-4111-8111-111111111111");
expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:01:00.000Z"
);
await canvasService.saveCanvasState(
"11111111-1111-4111-8111-111111111111",
canvasData
);
expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:01:05.000Z"
);
expect(useCanvasStore.getState().canvasUpdatedAt).toBe(
"2026-06-22T00:01:05.000Z"
);
} finally {
globalThis.fetch = originalFetch;
if (hadWindow) {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
writable: true,
});
} else {
Reflect.deleteProperty(globalThis, "window");
}
useCanvasStore.getState().setCanvasRevision(null, null);
}
});
test("canvas service automatically retries stale save conflicts", async () => {
const originalFetch = globalThis.fetch;
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const originalWindow = (globalThis as typeof globalThis & { window?: unknown })
.window;
const bodies: Array> = [];
const responses = [
{
ok: false,
status: 409,
json: async () => ({
success: false,
error: "画布已更新,请刷新后再保存",
code: "STALE_CANVAS_REVISION",
canvasUpdatedAt: "2026-06-22T00:02:00.000Z",
}),
},
{
ok: true,
status: 200,
json: async () => ({
success: true,
data: { updatedAt: "2026-06-22T00:02:01.000Z" },
}),
},
];
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {},
writable: true,
});
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body || "{}")));
return responses.shift() as Response;
}) as typeof fetch;
try {
const result = await canvasService.saveCanvasState(
"11111111-1111-4111-8111-111111111111",
{
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select",
}
);
expect(result).toMatchObject({
success: true,
data: { updatedAt: "2026-06-22T00:02:01.000Z" },
});
expect(bodies).toHaveLength(2);
expect(bodies[1]).toMatchObject({
baseCanvasRevision: "2026-06-22T00:02:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:02:00.000Z",
});
expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:02:01.000Z"
);
} finally {
globalThis.fetch = originalFetch;
if (hadWindow) {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
writable: true,
});
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
});
const plannerContext: CanvasAgentContext = {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
canvasRevision: "2026-06-22T00:00:00.000Z",
canvasUpdatedAt: "2026-06-22T00:00:00.000Z",
viewport: { x: 0, y: 0, zoom: 1 },
selectedNodeIds: ["image-1", "text-1"],
selectedNodes: [
{
nodeId: "image-1",
type: "image",
title: "主图",
x: 10,
y: 20,
width: 300,
height: 240,
isSelected: true,
},
{
nodeId: "text-1",
type: "text",
title: "旧标题",
x: 40,
y: 80,
width: 240,
height: 80,
isSelected: true,
},
],
candidateNodes: [],
slots: [],
annotations: [],
focusPolicy: "selected",
};
test("keeps canvas model planner scoped to low-risk canvas operations", () => {
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "把选中的文案润色得更高级",
context: plannerContext,
})
).toBe(true);
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "给这张图换成夏天户外背景,新图放右边",
context: plannerContext,
})
).toBe(false);
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "删除背景,结果放右边",
context: plannerContext,
})
).toBe(false);
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "新建一个视频槽位",
context: plannerContext,
})
).toBe(true);
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "对比原图和新图",
context: plannerContext,
})
).toBe(true);
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "清空选中的节点",
context: plannerContext,
})
).toBe(true);
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "向右移动视角一点",
context: plannerContext,
})
).toBe(true);
});
test("treats selected agent slots as canvas routing context", () => {
expect(
hasCanvasAgentRoutingContext({
hasCanvasSelection: false,
targetResolutionMode: "none",
mediaSource: "none",
selectedImageCount: 0,
selectedSlotCount: 1,
hasSelectedAgentSlot: true,
})
).toBe(true);
expect(
hasCanvasAgentRoutingContext({
hasCanvasSelection: false,
targetResolutionMode: "none",
mediaSource: "none",
selectedImageCount: 0,
selectedSlotCount: 0,
hasSelectedAgentSlot: false,
})
).toBe(false);
});
test("normalizes model-planned empty slot creation", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "create_agent_slot",
slotType: "video",
slotLabel: "片头视频槽位",
},
context: {
...plannerContext,
viewport: { x: -100, y: -50, zoom: 2, width: 1000, height: 800 },
},
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "新建一个视频槽位",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "create_agent_slot",
targetPosition: { x: 120, y: 115 },
slot: {
slotType: "video",
label: "片头视频槽位",
aspectRatio: "16:9",
fillPolicy: "replace-empty",
},
});
});
test("builds direct-media slot creation plans for combined slot requests", () => {
const intent = resolveCanvasAgentSlotCreationIntent({
userText: "新建一个 9:16 视频槽位并生成片头",
});
expect(intent).toMatchObject({
shouldCreate: true,
combinedWithMediaExecution: true,
slotType: "video",
aspectRatio: "9:16",
});
const plan = buildCanvasAgentSlotCreationPlan({
context: {
...plannerContext,
viewport: { x: 0, y: 0, zoom: 1, width: 1000, height: 800 },
},
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "新建一个 9:16 视频槽位并生成片头",
operationId: "message-1:agent-generate-video:create-slot",
});
expect(plan).toMatchObject({
operationId: "message-1:agent-generate-video:create-slot",
operation: "create_agent_slot",
targetPosition: { x: 320, y: 290 },
slot: {
slotType: "video",
aspectRatio: "9:16",
fillPolicy: "replace-empty",
},
});
});
test("leaves combined slot creation and media generation to media executors", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "create_agent_slot",
slotType: "video",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "创建一个视频槽位并生成结果",
});
expect(outcome).toMatchObject({
kind: "none",
reason: "slot_creation_combined_with_media_execution",
});
});
test("plans direct writeback as a slot fill when an agent slot is selected", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: ["slot-1"],
selectedNodes: [
{
nodeId: "slot-1",
type: "agent-slot",
title: "主图槽位",
x: 100,
y: 120,
width: 320,
height: 240,
isSelected: true,
},
],
slots: [
{
nodeId: "slot-1",
slotType: "image",
label: "主图槽位",
x: 100,
y: 120,
width: 320,
height: 240,
aspectRatio: "4:3",
isSelected: true,
fillPolicy: "replace-empty",
},
],
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-1",
operationLabel: "agent-generate-image",
resultType: "image",
resultUrls: ["https://cdn.example.com/slot.png"],
prompt: "生成主图",
mode: "generate",
context,
model: "image-model",
});
expect(result).toMatchObject({
operationId: "assistant-1:agent-generate-image",
operation: "fill_slot",
targetSlotId: "slot-1",
shouldFillSlot: true,
mediaItemIds: ["assistant-1:agent-generate-image:1"],
plan: {
operation: "fill_slot",
targetSlotId: "slot-1",
anchorNodeId: undefined,
placementStrategy: "single",
sourceNodeIds: ["slot-1"],
},
});
expect(result?.mediaItems[0]).toMatchObject({
mediaItemId: "assistant-1:agent-generate-image:1",
type: "image",
url: "https://cdn.example.com/slot.png",
prompt: "生成主图",
sourceNodeIds: ["slot-1"],
metadata: {
messageId: "assistant-1",
chatId: "chat-1",
model: "image-model",
},
});
});
test("does not fill a selected agent slot when the result type mismatches", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: ["slot-video-1"],
selectedNodes: [
{
nodeId: "slot-video-1",
type: "agent-slot",
title: "视频槽位",
x: 100,
y: 120,
width: 320,
height: 180,
isSelected: true,
},
],
slots: [
{
nodeId: "slot-video-1",
slotType: "video",
label: "视频槽位",
x: 100,
y: 120,
width: 320,
height: 180,
aspectRatio: "16:9",
isSelected: true,
fillPolicy: "replace-empty",
},
],
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-mismatch",
operationLabel: "agent-generate-image",
resultType: "image",
resultUrls: ["https://cdn.example.com/not-video-slot.png"],
prompt: "生成主图",
mode: "generate",
context,
model: "image-model",
});
expect(result).toMatchObject({
operation: "insert_beside",
targetSlotId: "",
shouldFillSlot: false,
plan: {
operation: "insert_beside",
targetSlotId: undefined,
anchorNodeId: "slot-video-1",
placement: "right",
},
});
expect(result?.mediaItems[0]).toMatchObject({
type: "image",
sourceNodeIds: ["slot-video-1"],
});
});
test("plans direct image edits as append revisions anchored to selected media", () => {
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-2",
operationLabel: "agent-edit-image",
resultType: "image",
resultUrls: [
"relative.png",
"https://cdn.example.com/revision.png",
],
prompt: "",
requestTextContent: "把选中图片改成黄昏光线",
mode: "edit",
context: plannerContext,
targetRefs: [{ nodeId: "image-1" }],
sourceImageUrls: ["https://cdn.example.com/source.png"],
assetIdsByUrl: {
"https://cdn.example.com/revision.png": "asset-1",
},
});
expect(result).toMatchObject({
operation: "append_revision",
shouldFillSlot: false,
mediaItemIds: ["assistant-2:agent-edit-image:1"],
plan: {
operation: "append_revision",
anchorNodeId: "image-1",
placementStrategy: "single",
metadata: {
prompt: "把选中图片改成黄昏光线",
},
},
});
expect(result?.mediaItems[0]).toMatchObject({
assetId: "asset-1",
sourceNodeIds: ["image-1"],
metadata: {
sourceImageUrls: ["https://cdn.example.com/source.png"],
},
});
});
test("routes watermark removal through the dedicated direct image tool", () => {
const result = resolveDirectAgentToolExecution({
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_watermark",
targetSource: "explicit_selection",
preferredCapability: "remove_watermark",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [
{
name: "resolveTargetImage",
args: { source: "selected_canvas" },
},
{
name: "editTargetImage",
reason: "planner only provided the generic edit tool",
},
],
availableTools: ["resolveTargetImage", "editTargetImage", "removeWatermark"],
});
expect(result).toMatchObject({
toolName: "removeWatermark",
capability: "remove_watermark",
sourcePreferences: ["selected_canvas"],
});
expect(getDirectAgentToolLabel("remove_watermark")).toBe("Agent工具:去水印");
expect(getDirectAgentToolSuccessMessage("remove_watermark")).toBe(
"已完成去水印处理,结果如下:"
);
});
test("formats direct agent image results as structured markdown cards", () => {
const generatedText = buildDirectAgentImageGenerationAssistantText({
imageMarkdown: "",
modelDisplayName: "Test Image Model",
});
expect(generatedText).toContain("### 执行结果");
expect(generatedText).toContain("- 已完成图片生成,结果如下。");
expect(generatedText).toContain("");
expect(generatedText).toContain("### 后续操作");
const editText = buildDirectAgentImageEditAssistantText({
capability: "remove_background",
originalUrl: "https://cdn.example.com/cutout.png",
});
expect(editText).toContain("### 执行结果");
expect(editText).toContain("- 已完成抠图处理,结果如下。");
expect(editText).toContain("https://cdn.example.com/cutout.png");
expect(editText).toContain("◈ 抠图");
expect(editText).not.toContain("Agent工具:");
expect(editText).toContain("替换背景");
});
test("uses user-facing direct agent tool labels in fallbacks", () => {
expect(getDirectAgentToolDisplayName("generic_edit")).toBe("图片编辑");
expect(getDirectAgentToolDisplayName("local_edit")).toBe("局部编辑");
expect(getDirectAgentToolPrefaceFallback("generic_edit")).toBe(
"好的,我来帮你修改这张图,稍等我一下。"
);
expect(getDirectAgentToolPrefaceFallback("generic_edit")).not.toContain("Agent工具:");
});
test("builds capability-aware agent progress node labels", () => {
const selectingSteps = buildAgentProgressNodeSteps({
stage: "planning",
currentStep: "select_tool",
capability: "remove_background",
});
expect(selectingSteps.map((step) => step.label)).toEqual([
"理解用户需求",
"分析使用工具",
"确认抠图工具",
"抠图中",
"整理结果",
]);
expect(selectingSteps.map((step) => step.state)).toEqual([
"completed",
"completed",
"current",
"upcoming",
"upcoming",
]);
const executingSteps = buildAgentProgressNodeSteps({
stage: "executing",
currentStep: "execute",
capability: "remove_background",
});
expect(executingSteps[2]?.state).toBe("completed");
expect(executingSteps[3]).toMatchObject({
label: "抠图中",
state: "current",
});
expect(getAgentProgressStepLabel("execute", { capability: "generate_video" })).toBe(
"视频生成中"
);
});
test("resolves deictic canvas edit requests to latest results and direct tools", () => {
const latestResultUrl = "https://cdn.example.com/latest-cat-result.png";
const requestPolicy = resolveCanvasAgentRequestPolicy({
composerMode: "agent",
selectedChatModel: "conversation-model-id",
});
expect(requestPolicy.effectiveSelectedChatModel).toBe("auto");
expect(requestPolicy.shouldForceToolOrchestration).toBe(true);
const targetPreference = resolveCanvasAgentTargetPreference({
text: "将这只猫从图中扣图扣出来",
selectedImageCount: 0,
latestResultImageCount: 1,
workingMemoryTargetImageCount: 1,
});
expect(targetPreference.shouldPreferLatestResult).toBe(true);
const mediaInput = {
imageRefs: [],
currentMessageImageRefs: [],
selectedImageRefs: [],
canvasImageRefs: [],
historyUserImageRefs: [],
historyAssistantImageRefs: [
{
url: latestResultUrl,
normalizedUrl: latestResultUrl,
source: "history_assistant_result",
role: "history_result",
},
],
resolvedImageRefs: [],
currentMessageImageUrls: [],
selectedImageUrls: [],
historyUserImageUrls: [],
historyAssistantImageUrls: [latestResultUrl],
resolvedImageUrls: [latestResultUrl],
selectedAssetIds: [],
candidateAssetIds: [],
uploadedAssetIds: [],
hasImage: true,
source: "history",
fromHistory: true,
} as any;
const workingState = {
lastGeneratedImageUrls: [latestResultUrl],
lastResolvedTargetImageUrls: [latestResultUrl],
};
const targetResolution = {
targetRefs: [],
candidateRefs: [],
targetImageUrls: [latestResultUrl],
targetAssetIds: [],
candidateImageUrls: [],
candidateAssetIds: [],
resolutionMode: "working_memory",
confidence: targetPreference.confidence,
needsClarification: false,
reason: targetPreference.reason,
fromHistory: true,
} as any;
const directContext = resolveDirectAgentToolContext({
execution: {
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_background",
targetSource: targetResolution.resolutionMode,
preferredCapability: "remove_background",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [],
availableTools: ["removeBackground"],
} as any,
requestResolvedMediaInput: mediaInput,
requestTargetResolution: targetResolution,
requestWorkingState: workingState,
});
expect(directContext?.execution.toolName).toBe("removeBackground");
expect(directContext?.targetImageUrl).toBe(latestResultUrl);
});
test("falls back to canvas images for direct agent edit tools", () => {
const canvasImageUrl = "https://cdn.example.com/canvas-cat.png";
const mediaInput = {
imageRefs: [],
currentMessageImageRefs: [],
selectedImageRefs: [],
canvasImageRefs: [
{
url: canvasImageUrl,
normalizedUrl: canvasImageUrl,
nodeId: "cat-node",
source: "canvas_context",
role: "reference",
x: 120,
y: 80,
width: 640,
height: 640,
isSelected: false,
},
],
historyUserImageRefs: [],
historyAssistantImageRefs: [],
resolvedImageRefs: [],
currentMessageImageUrls: [],
selectedImageUrls: [],
canvasImageUrls: [canvasImageUrl],
historyUserImageUrls: [],
historyAssistantImageUrls: [],
resolvedImageUrls: [],
selectedAssetIds: [],
candidateAssetIds: [],
uploadedAssetIds: [],
hasImage: false,
source: "none",
fromHistory: false,
} as any;
const directContext = resolveDirectAgentToolContext({
execution: {
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_background",
targetSource: "text_reference",
preferredCapability: "remove_background",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [],
availableTools: ["removeBackground"],
} as any,
requestResolvedMediaInput: mediaInput,
});
expect(directContext?.execution.toolName).toBe("removeBackground");
expect(directContext?.targetImageUrl).toBe(canvasImageUrl);
});
test("routes canvas subject cutout wording to remove background instead of image generation", () => {
const canvasImageUrl = "https://cdn.example.com/canvas-dog.png";
const userText = "帮我将画布中的狗的图扣出来";
expect(isExplicitImageGenerationRequest(userText)).toBe(false);
expect(inferCanvasAgentImageEditCapability(userText)).toBe("remove_background");
const directContext = resolveDirectAgentToolContext({
execution: {
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_background",
targetSource: "text_reference",
preferredCapability: "remove_background",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [],
availableTools: ["resolveTargetImage", "removeBackground"],
} as any,
requestResolvedMediaInput: {
imageRefs: [],
currentMessageImageRefs: [],
selectedImageRefs: [],
canvasImageRefs: [
{
url: canvasImageUrl,
normalizedUrl: canvasImageUrl,
nodeId: "dog-node",
source: "canvas_context",
role: "reference",
},
],
historyUserImageRefs: [],
historyAssistantImageRefs: [],
resolvedImageRefs: [],
currentMessageImageUrls: [],
selectedImageUrls: [],
canvasImageUrls: [canvasImageUrl],
historyUserImageUrls: [],
historyAssistantImageUrls: [],
resolvedImageUrls: [],
selectedAssetIds: [],
candidateAssetIds: [],
uploadedAssetIds: [],
hasImage: true,
source: "canvas_context",
fromHistory: false,
} as any,
});
expect(directContext?.execution.toolName).toBe("removeBackground");
expect(directContext?.targetImageUrl).toBe(canvasImageUrl);
});
test("only runs direct image edit batches for explicit safe batch requests", () => {
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "remove_watermark",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "把这几张都去水印",
})
).toBe(true);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "remove_background",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "每张分别抠图",
})
).toBe(true);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "remove_watermark",
sourceImageUrls: ["https://cdn.example.com/a.png"],
userText: "全部去水印",
})
).toBe(false);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "generic_edit",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "把这几张都改成黄昏",
})
).toBe(false);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "angle_variant",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "这两张都生成右侧面视角",
})
).toBe(true);
expect(hasExplicitDirectAgentBatchImageEditIntent("只处理第一张")).toBe(false);
expect(isDirectAgentBatchImageEditCapability("box_cutout")).toBe(false);
});
test("routes angle variant requests through the dedicated direct image tool", () => {
const result = resolveDirectAgentToolExecution({
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "angle_variant",
targetSource: "explicit_selection",
preferredCapability: "angle_variant",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [
{
name: "resolveTargetImage",
args: { source: "selected_canvas" },
},
{
name: "editTargetImage",
reason: "planner only provided the generic edit tool",
},
],
availableTools: ["resolveTargetImage", "editTargetImage", "angleEditImage"],
});
expect(result).toMatchObject({
toolName: "angleEditImage",
capability: "angle_variant",
sourcePreferences: ["selected_canvas"],
});
expect(getDirectAgentToolLabel("angle_variant")).toBe("Agent工具:多视角");
expect(getDirectAgentToolSuccessMessage("angle_variant")).toBe(
"已完成多视角生成,结果如下:"
);
});
test("routes local edit requests through the dedicated direct image tool", () => {
const result = resolveDirectAgentToolExecution({
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "local_edit",
targetSource: "explicit_selection",
preferredCapability: "local_edit",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [
{
name: "resolveTargetImage",
args: { source: "selected_canvas" },
},
{
name: "editTargetImage",
reason: "planner only provided the generic edit tool",
},
],
availableTools: ["resolveTargetImage", "editTargetImage", "localEditImage"],
});
expect(result).toMatchObject({
toolName: "localEditImage",
capability: "local_edit",
sourcePreferences: ["selected_canvas"],
});
expect(getDirectAgentToolLabel("local_edit")).toBe("Agent工具:局部编辑");
expect(getDirectAgentToolSuccessMessage("local_edit")).toBe(
"已完成局部编辑,结果如下:"
);
expect(isDirectAgentBatchImageEditCapability("local_edit")).toBe(false);
});
test("resolves direct local edit regions from selected canvas annotations", () => {
const context: CanvasAgentContext = {
version: 1,
source: "canvas",
projectId: "project-1",
selectedNodeIds: ["image-1"],
selectedNodes: [
{
nodeId: "image-1",
type: "image",
url: "https://cdn.example.com/source.png",
x: 0,
y: 0,
isSelected: true,
},
],
candidateNodes: [],
slots: [],
annotations: [
{
id: "annotation-1",
sourceNodeId: "image-1",
type: "crop-box",
text: "把杯子换成蓝色陶瓷杯",
bounds: { x: 0.2, y: 0.3, width: 0.25, height: 0.2 },
color: "#1677ff",
},
{
id: "annotation-other",
sourceNodeId: "image-2",
type: "crop-box",
text: "不应带入",
bounds: { x: 0.1, y: 0.1, width: 0.1, height: 0.1 },
},
],
focusPolicy: "selected",
};
const result = resolveDirectAgentLocalEditRegions({
canvasAgentContext: context,
requestTextContent: "按批注修改",
sourceImageUrl: "https://cdn.example.com/source.png",
targetNodeIds: ["image-1"],
});
expect(result.sourceNodeId).toBe("image-1");
expect(result.regions).toEqual([
{
id: "annotation-1",
color: "#1677ff",
prompt: "把杯子换成蓝色陶瓷杯",
rect: { x: 0.2, y: 0.3, width: 0.25, height: 0.2 },
},
]);
expect(hasDirectAgentLocalEditIntent("按照批注修改这张图")).toBe(true);
expect(hasDirectAgentLocalEditIntent("给这张图添加批注")).toBe(false);
expect(
normalizeDirectAgentLocalEditRect({
x: 0.8,
y: 0.8,
width: 0.4,
height: 0.3,
})
).toEqual({ x: 0.8, y: 0.8, width: 0.2, height: 0.2 });
});
test("infers deterministic angle edit options from canvas agent text", () => {
expect(inferAngleEditOptionsFromText("生成右侧面近景视角")).toMatchObject({
horizontalAngle: 90,
verticalAngle: 0,
zoom: 7,
});
expect(inferAngleEditOptionsFromText("改成背面俯视远景")).toMatchObject({
horizontalAngle: 180,
verticalAngle: 45,
zoom: 3,
});
expect(inferAngleEditOptionsFromText("水平角120度,垂直角-10度,zoom8")).toMatchObject({
horizontalAngle: 120,
verticalAngle: -10,
zoom: 8,
});
});
test("plans watermark removal results as non-destructive canvas revisions", () => {
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-watermark",
operationLabel: "agent-remove_watermark",
resultType: "image",
resultUrls: ["https://cdn.example.com/watermark-removed.png"],
prompt: "把选中图片上的水印去掉",
requestTextContent: "把选中图片上的水印去掉,结果放右边",
mode: "edit",
context: plannerContext,
targetRefs: [{ nodeId: "image-1" }],
sourceImageUrls: ["https://cdn.example.com/source-watermarked.png"],
});
expect(result).toMatchObject({
operation: "append_revision",
shouldFillSlot: false,
mediaItemIds: ["assistant-watermark:agent-remove_watermark:1"],
plan: {
operation: "append_revision",
anchorNodeId: "image-1",
placement: "right",
preserveOriginal: true,
},
});
expect(result?.mediaItems[0]).toMatchObject({
type: "image",
url: "https://cdn.example.com/watermark-removed.png",
sourceNodeIds: ["image-1"],
metadata: {
sourceImageUrls: ["https://cdn.example.com/source-watermarked.png"],
},
});
});
test("anchors direct image edits to resolved target refs before fallback selection", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: [],
selectedNodes: [],
candidateNodes: [
{
nodeId: "image-left",
type: "image",
title: "左图",
x: 0,
y: 0,
width: 240,
height: 180,
},
{
nodeId: "image-right",
type: "image",
title: "右图",
x: 420,
y: 0,
width: 240,
height: 180,
},
],
focusPolicy: "auto",
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-targeted",
operationLabel: "agent-edit-image",
resultType: "image",
resultUrls: ["https://cdn.example.com/right-revision.png"],
prompt: "把右边那张背景换成白色",
mode: "edit",
context,
targetRefs: [{ nodeId: " image-right " }],
sourceImageUrls: ["https://cdn.example.com/right-source.png"],
});
expect(result).toMatchObject({
operation: "append_revision",
plan: {
operation: "append_revision",
anchorNodeId: "image-right",
sourceNodeIds: ["image-right"],
},
});
expect(result?.selectedMediaNodes.map((node) => node.nodeId)).toEqual([
"image-right",
]);
expect(result?.mediaItems[0]).toMatchObject({
sourceNodeIds: ["image-right"],
metadata: {
sourceImageUrls: ["https://cdn.example.com/right-source.png"],
},
});
});
test("records annotation trace metadata for direct annotation edits", () => {
const context: CanvasAgentContext = {
...plannerContext,
annotations: [
{
id: "annotation-keep-bg",
sourceNodeId: "image-1",
type: "text-note",
text: "背景换成浅灰",
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
},
{
id: "annotation-other",
sourceNodeId: "image-other",
type: "text-note",
text: "不应进入 image-1 的结果 metadata",
},
],
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-annotation-edit",
operationLabel: "agent-edit-image",
resultType: "image",
resultUrls: ["https://cdn.example.com/annotation-edit.png"],
prompt: "按批注修改这张图",
requestTextContent: "按批注修改这张图",
mode: "edit",
context,
targetRefs: [{ nodeId: "image-1" }],
});
expect(result?.mediaItems[0]?.metadata).toMatchObject({
canvasAgentAnnotationIds: ["annotation-keep-bg"],
canvasAgentAnnotationCount: 1,
});
expect(
String(result?.mediaItems[0]?.metadata?.canvasAgentAnnotationBrief || "")
).toContain("背景换成浅灰");
expect(
String(result?.mediaItems[0]?.metadata?.canvasAgentAnnotationBrief || "")
).not.toContain("annotation-other");
});
test("plans direct batch image edits beside each matched source", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: ["image-a", "image-b"],
selectedNodes: [
{
nodeId: "image-a",
type: "image",
title: "A",
x: 0,
y: 0,
width: 200,
height: 160,
isSelected: true,
},
{
nodeId: "image-b",
type: "image",
title: "B",
x: 260,
y: 0,
width: 200,
height: 160,
isSelected: true,
},
],
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-batch-edit",
operationLabel: "agent-remove_watermark",
resultType: "image",
resultUrls: [
"https://cdn.example.com/a-clean.png",
"https://cdn.example.com/b-clean.png",
],
prompt: "这两张都去水印",
requestTextContent: "这两张都去水印",
mode: "edit",
context,
targetRefs: [{ nodeId: "image-a" }, { nodeId: "image-b" }],
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
});
expect(result).toMatchObject({
operation: "append_revision",
shouldFillSlot: false,
plan: {
operation: "append_revision",
anchorNodeId: "image-a",
placementStrategy: "beside_each_source",
sourceNodeIds: ["image-a", "image-b"],
},
});
expect(result?.mediaItems.map((item) => item.sourceNodeIds)).toEqual([
["image-a"],
["image-b"],
]);
});
test("plans direct batch generations beside each matched source", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: ["image-a", "image-b"],
selectedNodes: [
{
nodeId: "image-a",
type: "image",
title: "A",
x: 0,
y: 0,
width: 200,
height: 160,
isSelected: true,
},
{
nodeId: "image-b",
type: "image",
title: "B",
x: 260,
y: 0,
width: 200,
height: 160,
isSelected: true,
},
],
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-3",
operationLabel: "agent-generate-image",
resultType: "image",
resultUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
prompt: "分别生成两个变体",
mode: "generate",
context,
targetRefs: [{ nodeId: "image-a" }, { nodeId: "image-b" }],
});
expect(result).toMatchObject({
operation: "insert_beside",
shouldFillSlot: false,
plan: {
operation: "insert_beside",
anchorNodeId: "image-a",
placementStrategy: "beside_each_source",
sourceNodeIds: ["image-a", "image-b"],
},
});
expect(result?.mediaItems.map((item) => item.sourceNodeIds)).toEqual([
["image-a"],
["image-b"],
]);
});
test("plans direct generations into the current viewport when no anchor exists", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: [],
selectedNodes: [],
candidateNodes: [],
slots: [],
annotations: [],
viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 800 },
focusPolicy: "auto",
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-no-anchor",
operationLabel: "agent-generate-image",
resultType: "image",
resultUrls: ["https://cdn.example.com/no-anchor.png"],
prompt: "生成一张主图",
mode: "generate",
context,
});
expect(result).toMatchObject({
operation: "insert_beside",
shouldFillSlot: false,
plan: {
operation: "insert_beside",
anchorNodeId: undefined,
placement: "viewport-center",
placementStrategy: "single",
sourceNodeIds: [],
metadata: {
canvasAgentViewport: {
x: -200,
y: -100,
zoom: 1,
width: 1000,
height: 800,
},
},
},
});
expect(result?.mediaItems[0]?.sourceNodeIds).toEqual([]);
});
test("does not build direct writeback plans without valid result urls", () => {
expect(
buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-4",
operationLabel: "agent-generate-image",
resultType: "image",
resultUrls: ["", "blob:local", "/relative.png"],
prompt: "生成图片",
mode: "generate",
context: plannerContext,
})
).toBeNull();
});
test("normalizes model-planned focus to known canvas nodes only", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "focus_nodes",
nodeIds: ["missing-node", "image-1"],
reply: "已聚焦主图。",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "帮我定位到主图",
});
expect(outcome.kind).toBe("view");
expect(outcome.kind === "view" ? outcome.viewPlan.nodeIds : []).toEqual([
"image-1",
]);
});
test("normalizes model-planned text updates to selected text nodes", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "update_text",
nodeIds: ["image-1", "text-1"],
textContent: "轻盈强吸力,家务更省心",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中文案改得更高级",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "update_text",
targetNodeIds: ["text-1"],
textContent: "轻盈强吸力,家务更省心",
});
});
test("normalizes model-planned text creation into new canvas text nodes", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: [],
selectedNodes: [],
viewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
focusPolicy: "auto",
};
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "create_text_node",
textContent: "618 限时优惠,新品主推",
},
context,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "在画布上添加一段促销文案:618 限时优惠,新品主推",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "create_text_node",
targetPosition: { x: 440, y: 340 },
textContent: "618 限时优惠,新品主推",
preserveOriginal: true,
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "model",
canvasAgentViewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
},
});
});
test("turns new-text update decisions into text creation when no text node is targeted", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: ["image-1"],
selectedNodes: [plannerContext.selectedNodes[0]],
viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 700 },
focusPolicy: "selected",
};
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "update_text",
nodeIds: ["image-1"],
textContent: "新品上市",
},
context,
projectId: plannerContext.projectId || "",
messageId: "message-2",
chatId: "chat-1",
userText: "在画布上添加文字:新品上市",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "create_text_node",
targetPosition: { x: 540, y: 390 },
textContent: "新品上市",
metadata: {
normalizedFromAction: "update_text",
},
});
});
test("normalizes model-planned node renames without requiring text nodes", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "rename_node",
nodeIds: ["image-1"],
textContent: "白底主图 A",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中图片标题改成白底主图 A",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "rename_node",
targetNodeIds: ["image-1"],
textContent: "白底主图 A",
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "model",
},
});
});
test("normalizes model-planned image notes as note updates", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "update_note",
nodeIds: ["image-1", "text-1"],
textContent: "保留主体轮廓,背景可以更干净",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "给选中图片备注:保留主体轮廓,背景可以更干净",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "update_note",
targetNodeIds: ["image-1"],
textContent: "保留主体轮廓,背景可以更干净",
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "model",
},
});
});
test("builds deterministic text creation plans from explicit canvas prompts", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: [],
selectedNodes: [],
viewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
focusPolicy: "auto",
};
const outcome = buildCanvasAgentExplicitOperationPlan({
context,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "在画布上添加文字:新品上市",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "create_text_node",
targetPosition: { x: 440, y: 340 },
textContent: "新品上市",
preserveOriginal: true,
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
canvasAgentViewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
},
});
});
test("builds deterministic text update plans for selected text nodes", () => {
const outcome = buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中文案改成:轻盈强吸力,家务更省心",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "update_text",
targetNodeIds: ["text-1"],
textContent: "轻盈强吸力,家务更省心",
preserveOriginal: true,
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
expect(outcome?.reply).toContain("文本");
});
test("builds deterministic link plans for selected canvas nodes", () => {
const outcome = buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中的两个节点连成参考关系",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "link_nodes",
sourceNodeIds: ["image-1", "text-1"],
edgeType: "reference",
preserveOriginal: true,
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
expect(outcome?.reply).toContain("参考连线");
});
test("builds deterministic agent slot creation plans from explicit canvas prompts", () => {
const context: CanvasAgentContext = {
...plannerContext,
viewport: { x: 0, y: 0, zoom: 1, width: 1000, height: 800 },
focusPolicy: "auto",
};
const outcome = buildCanvasAgentExplicitOperationPlan({
context,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "新建一个 9:16 视频槽位",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "create_agent_slot",
targetPosition: { x: 320, y: 290 },
slot: {
slotType: "video",
label: "视频槽位",
aspectRatio: "9:16",
fillPolicy: "replace-empty",
acceptedSources: ["text", "image", "selection", "annotation"],
},
preserveOriginal: true,
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
expect(outcome?.reply).toContain("视频槽位");
const imageOutcome = buildCanvasAgentExplicitOperationPlan({
context,
projectId: plannerContext.projectId || "",
messageId: "message-2",
chatId: "chat-1",
userText: "创建一个图片槽位",
});
expect(imageOutcome?.operationPlan).toMatchObject({
operation: "create_agent_slot",
targetPosition: { x: 340, y: 240 },
slot: {
slotType: "image",
label: "图片槽位",
aspectRatio: "1:1",
fillPolicy: "replace-empty",
},
metadata: {
messageId: "message-2",
chatId: "chat-1",
planner: "deterministic",
},
});
expect(imageOutcome?.reply).toContain("图片槽位");
});
test("builds deterministic node rename plans without treating titles as image edits", () => {
const outcome = buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中图片改名为白底主图 A",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "rename_node",
targetNodeIds: ["image-1"],
textContent: "白底主图 A",
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
});
test("builds deterministic note updates for selected media nodes only", () => {
const outcome = buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "给选中图片备注:保留主体轮廓",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "update_note",
targetNodeIds: ["image-1"],
textContent: "保留主体轮廓",
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
});
test("builds deterministic annotation additions for selected media nodes only", () => {
const outcome = buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "给选中图片加批注:这里需要重点保留产品轮廓",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "add_annotation",
targetNodeIds: ["image-1"],
textContent: "这里需要重点保留产品轮廓",
annotations: [
{
type: "text-note",
text: "这里需要重点保留产品轮廓",
color: "#f59e0b",
},
],
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
});
test("does not route media generation or note-driven image edits as explicit canvas operations", () => {
expect(
buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "按这个备注修改主图",
})
).toBeNull();
expect(
buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "生成一张图,标题文字:新品上市",
})
).toBeNull();
expect(
buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "新建一个视频槽位并生成片头",
})
).toBeNull();
expect(
buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "按这张图上的批注修改",
})
).toBeNull();
expect(
buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把图片里的文字改成新品上市",
})
).toBeNull();
expect(
buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "生成一张图并和选中图连接",
})
).toBeNull();
});
test("normalizes model-planned annotations to selected media nodes", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "add_annotation",
nodeIds: ["image-1", "text-1"],
textContent: "这里需要重点保留产品轮廓",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "给选中图片加备注:这里需要重点保留产品轮廓",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "add_annotation",
targetNodeIds: ["image-1"],
textContent: "这里需要重点保留产品轮廓",
annotations: [
{
type: "text-note",
text: "这里需要重点保留产品轮廓",
},
],
});
});
test("turns destructive model-planned canvas requests into confirmation", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "none",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "删除选中的这两项",
});
expect(outcome).toMatchObject({
kind: "confirmation_required",
riskOperation: "delete_nodes",
targetNodeIds: ["image-1", "text-1"],
});
const clearOutcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "none",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "清除选中的对象",
});
expect(clearOutcome).toMatchObject({
kind: "confirmation_required",
riskOperation: "delete_nodes",
targetNodeIds: ["image-1", "text-1"],
});
const removeBackgroundOutcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "none",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "删除背景,结果放右边",
});
expect(removeBackgroundOutcome).toEqual({
kind: "none",
reason: undefined,
});
});
test("builds pending move-node operation from model-planned high-risk layout", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "confirmation_required",
riskOperation: "move_nodes",
nodeIds: ["image-1", "text-1"],
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中的两项整理成一行",
});
expect(outcome.kind).toBe("confirmation_required");
expect(
outcome.kind === "confirmation_required" ? outcome.operationPlan : null
).toMatchObject({
operation: "move_nodes",
targetNodeIds: ["image-1", "text-1"],
layoutStrategy: "row",
});
expect(
outcome.kind === "confirmation_required" ? outcome.operationPlan?.confirmed : true
).toBeUndefined();
});
test("keeps viewport-centering move requests as view operations", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "focus_nodes",
nodeIds: ["image-1"],
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中的图移动到视野中央",
});
expect(outcome.kind).toBe("view");
expect(outcome.kind === "view" ? outcome.viewPlan : null).toMatchObject({
operation: "focus_selection",
nodeIds: ["image-1"],
anchorNodeId: "image-1",
persist: false,
});
});
test("builds pending delete-node operation from model-planned destructive request", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "confirmation_required",
riskOperation: "delete_nodes",
nodeIds: ["image-1", "text-1"],
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "删除选中的两项",
});
expect(outcome.kind).toBe("confirmation_required");
expect(
outcome.kind === "confirmation_required" ? outcome.operationPlan : null
).toMatchObject({
operation: "delete_nodes",
targetNodeIds: ["image-1", "text-1"],
metadata: {
pendingConfirmation: true,
riskOperation: "delete_nodes",
},
});
expect(
outcome.kind === "confirmation_required" ? outcome.operationPlan?.confirmed : true
).toBeUndefined();
});
test("extracts and confirms pending canvas operations from working state", () => {
const pending = buildPendingCanvasOperation({
projectId: plannerContext.projectId || "",
messageId: "message-1",
userText: "把选中的两项整理成一行",
riskOperation: "move_nodes",
targetNodeIds: ["image-1", "text-1"],
reply: "移动节点需要确认。",
operationPlan: {
version: 1,
operationId: "message-1:canvas-move",
operation: "move_nodes",
projectId: plannerContext.projectId || "",
targetNodeIds: ["image-1", "text-1"],
layoutStrategy: "row",
metadata: {
pendingConfirmation: true,
},
},
});
const extracted = extractPendingCanvasOperationFromWorkingState({
lastTurnSummary: {
pendingCanvasOperation: pending,
},
});
expect(extracted?.operationPlan?.operation).toBe("move_nodes");
expect(isCanvasOperationConfirmText("确认执行")).toBe(true);
expect(isCanvasOperationCancelText("先不执行了")).toBe(true);
const confirmedPlan = extracted
? withConfirmedCanvasOperationPlan(extracted, "message-2")
: null;
expect(confirmedPlan).toMatchObject({
operation: "move_nodes",
confirmed: true,
metadata: {
confirmedByUser: true,
confirmedByMessageId: "message-2",
},
});
});
test("confirms pending replace-node-media operations without executing directly", () => {
const pending = buildPendingCanvasOperation({
projectId: plannerContext.projectId || "",
messageId: "message-1",
userText: "直接替换原图",
riskOperation: "replace_node_media",
targetNodeIds: ["image-1"],
reply: "替换原图需要确认。",
operationPlan: {
version: 1,
operationId: "op-replace-confirmed",
operation: "replace_node_media",
projectId: plannerContext.projectId || "",
targetNodeIds: ["image-1"],
mediaItems: [
{
mediaItemId: "media-1",
type: "image",
url: "https://cdn.example.com/new.png",
assetId: "asset-new",
title: "新图",
prompt: "new prompt",
},
],
},
});
expect(pending.operationPlan?.confirmed).toBeUndefined();
const confirmedPlan = withConfirmedCanvasOperationPlan(pending, "message-2");
expect(confirmedPlan).toMatchObject({
operation: "replace_node_media",
confirmed: true,
targetNodeIds: ["image-1"],
mediaItems: [
{
mediaItemId: "media-1",
url: "https://cdn.example.com/new.png",
},
],
metadata: {
confirmedByUser: true,
confirmedByMessageId: "message-2",
},
});
});
test("confirms pending delete-node operations without executing directly", () => {
const pending = buildPendingCanvasOperation({
projectId: plannerContext.projectId || "",
messageId: "message-1",
userText: "删除选中的两项",
riskOperation: "delete_nodes",
targetNodeIds: ["image-1", "text-1"],
reply: "删除节点需要确认。",
operationPlan: {
version: 1,
operationId: "message-1:canvas-delete",
operation: "delete_nodes",
projectId: plannerContext.projectId || "",
targetNodeIds: ["image-1", "text-1"],
metadata: {
pendingConfirmation: true,
},
},
});
const confirmedPlan = withConfirmedCanvasOperationPlan(pending, "message-2");
expect(confirmedPlan).toMatchObject({
operation: "delete_nodes",
confirmed: true,
targetNodeIds: ["image-1", "text-1"],
metadata: {
confirmedByUser: true,
confirmedByMessageId: "message-2",
},
});
});
test("rejects confirmed stale high-risk canvas mutations", () => {
expect(
shouldRejectStaleCanvasAgentHighRiskMutation({
operation: "replace_node_media",
confirmed: false,
baseCanvasWasStale: true,
})
).toBe(false);
expect(
shouldRejectStaleCanvasAgentHighRiskMutation({
operation: "replace_node_media",
confirmed: true,
baseCanvasWasStale: true,
})
).toBe(true);
expect(
shouldRejectStaleCanvasAgentHighRiskMutation({
operation: "delete_nodes",
metadata: { confirmedByUser: true },
baseCanvasWasStale: true,
})
).toBe(true);
expect(
shouldRejectStaleCanvasAgentHighRiskMutation({
operation: "insert_beside",
confirmed: true,
baseCanvasWasStale: true,
})
).toBe(false);
expect(
shouldRejectStaleCanvasAgentHighRiskMutation({
operation: "move_nodes",
confirmed: true,
baseCanvasWasStale: false,
})
).toBe(false);
});
test("normalizes canvas operation error messages for stream feedback", () => {
expect(normalizeCanvasAgentOperationErrorCode("STALE_CANVAS_REVISION")).toBe(
"STALE_CANVAS_REVISION"
);
expect(normalizeCanvasAgentOperationErrorCode("unknown")).toBe(
"CANVAS_AGENT_WRITEBACK_FAILED"
);
expect(
normalizeCanvasAgentOperationErrorCode("CANVAS_AGENT_WRITEBACK_DISABLED")
).toBe("CANVAS_AGENT_WRITEBACK_DISABLED");
expect(
getCanvasAgentOperationErrorMessage({
code: "STALE_CANVAS_REVISION",
})
).toContain("刷新画布后重新确认");
expect(
getCanvasAgentOperationErrorMessage({
code: "CANVAS_AGENT_CONFIRMATION_REQUIRED",
})
).toContain("需要确认");
expect(
getCanvasAgentOperationErrorMessage({
code: "CANVAS_AGENT_CONFLICT",
})
).toContain("目标状态有冲突");
expect(
getCanvasAgentOperationErrorMessage({
code: "CANVAS_AGENT_WRITEBACK_DISABLED",
})
).toContain("灰度保护");
expect(
getCanvasAgentOperationErrorMessage({
code: "CANVAS_AGENT_WRITEBACK_FAILED",
fallbackMessage: "写回接口失败",
})
).toBe("写回接口失败");
});
test("normalizes canvas agent rollout flags and writeback modes", () => {
expect(normalizeCanvasAgentBoolean("true", false)).toBe(true);
expect(normalizeCanvasAgentBoolean("off", true)).toBe(false);
expect(normalizeCanvasAgentBoolean("surprise", true)).toBe(true);
expect(normalizeCanvasAgentWritebackMode("operation_shadow")).toBe(
"operation_shadow"
);
expect(normalizeCanvasAgentWritebackMode("nope")).toBe(
"operation_authoritative"
);
const defaults = getCanvasAgentFeatureFlags({});
expect(defaults).toMatchObject({
operationProtocolEnabled: true,
writebackMode: "operation_authoritative",
slotEnabled: true,
viewOperationEnabled: true,
annotationEditEnabled: true,
});
expect(canApplyCanvasAgentMutations(defaults)).toBe(true);
const shadow = getCanvasAgentFeatureFlags({
CANVAS_AGENT_WRITEBACK_MODE: "operation_shadow",
CANVAS_AGENT_SLOT_ENABLED: "0",
CANVAS_AGENT_VIEW_OPERATION_ENABLED: "false",
CANVAS_AGENT_ANNOTATION_EDIT_ENABLED: "off",
});
expect(shadow).toMatchObject({
writebackMode: "operation_shadow",
slotEnabled: false,
viewOperationEnabled: false,
annotationEditEnabled: false,
});
expect(canApplyCanvasAgentMutations(shadow)).toBe(false);
expect(shouldStreamCanvasAgentShadowOperations(shadow)).toBe(true);
const disabledProtocol = getCanvasAgentFeatureFlags({
CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED: "disabled",
CANVAS_AGENT_WRITEBACK_MODE: "operation_authoritative",
});
expect(canApplyCanvasAgentMutations(disabledProtocol)).toBe(false);
expect(shouldStreamCanvasAgentShadowOperations(disabledProtocol)).toBe(false);
const settingsOverride = getCanvasAgentFeatureFlags(
{
CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED: "disabled",
CANVAS_AGENT_WRITEBACK_MODE: "legacy_canvas_media",
CANVAS_AGENT_SLOT_ENABLED: "0",
},
{
[CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: true,
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: "operation_authoritative",
[CANVAS_AGENT_SLOT_ENABLED_KEY]: true,
[CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: false,
[CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: "off",
}
);
expect(settingsOverride).toMatchObject({
operationProtocolEnabled: true,
writebackMode: "operation_authoritative",
slotEnabled: true,
viewOperationEnabled: false,
annotationEditEnabled: false,
});
expect(canApplyCanvasAgentMutations(settingsOverride)).toBe(true);
expect(canvasAgentFeatureFlagsToSettings(settingsOverride)).toEqual({
operationProtocolEnabled: true,
writebackMode: "operation_authoritative",
slotEnabled: true,
viewOperationEnabled: false,
annotationEditEnabled: false,
});
expect(
canvasAgentFeatureSettingsToSystemSettings({
operationProtocolEnabled: false,
writebackMode: "operation_shadow",
slotEnabled: true,
viewOperationEnabled: true,
annotationEditEnabled: false,
})
).toEqual({
[CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: false,
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: "operation_shadow",
[CANVAS_AGENT_SLOT_ENABLED_KEY]: true,
[CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: true,
[CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: false,
});
expect(
normalizeCanvasAgentFeatureSettings({
operationProtocolEnabled: "off",
writebackMode: "invalid-mode",
slotEnabled: 0,
viewOperationEnabled: "on",
annotationEditEnabled: false,
})
).toEqual({
operationProtocolEnabled: false,
writebackMode: "operation_authoritative",
slotEnabled: false,
viewOperationEnabled: true,
annotationEditEnabled: false,
});
const invalidSettingsFallback = getCanvasAgentFeatureFlags(
{ CANVAS_AGENT_WRITEBACK_MODE: "operation_shadow" },
{
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: "not-a-mode",
}
);
expect(invalidSettingsFallback.writebackMode).toBe("operation_shadow");
});
test("builds idempotent canvas operation audit results", () => {
const canvasData = {
metadata: {
canvasAgentOperationAudit: [
{
operationId: " op-1 ",
operation: "link_nodes",
createdNodeIds: [" node-1 ", ""],
createdNodeIdsByMediaItemId: {
" media-1 ": " node-1 ",
"": "ignored",
"media-2": "",
},
updatedNodeIds: [" node-a ", "node-b"],
createdEdgeIds: [" edge-1 "],
},
],
},
};
const audit = findCanvasAgentOperationAudit(canvasData, "op-1");
expect(audit).toBeTruthy();
expect(
buildIdempotentCanvasAgentAuditResult({
canvasData,
audit: audit || {},
})
).toMatchObject({
canvasData,
createdNodeIds: ["node-1"],
createdNodeIdsByMediaItemId: { "media-1": "node-1" },
updatedNodeIds: ["node-a", "node-b"],
createdEdgeIds: ["edge-1"],
});
const unchanged = appendCanvasAgentOperationAuditIfMissing({
canvasData,
plan: {
version: 1,
operationId: "op-1",
operation: "link_nodes",
projectId: "11111111-1111-4111-8111-111111111111",
sourceNodeIds: ["node-a", "node-b"],
},
result: {
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: ["different"],
createdEdgeIds: ["different"],
},
});
expect(unchanged).toBe(canvasData);
const appended = appendCanvasAgentOperationAuditIfMissing({
canvasData,
plan: {
version: 1,
operationId: "op-2",
operation: "add_annotation",
projectId: "11111111-1111-4111-8111-111111111111",
targetNodeIds: ["node-a"],
},
result: {
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: ["node-a"],
createdEdgeIds: [],
},
});
expect(
findCanvasAgentOperationAudit(appended, "op-2")
).toMatchObject({
operationId: "op-2",
operation: "add_annotation",
updatedNodeIds: ["node-a"],
});
});
test("applies append revision canvas mutations without overwriting source nodes", () => {
const sourceNode = {
id: "image-1",
type: "image",
position: { x: 100, y: 120 },
size: { width: 320, height: 240 },
data: {
src: "https://cdn.example.com/source.png",
status: "completed",
title: "原图",
},
connections: [],
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
};
const result = applyCanvasOperationPlanToCanvasData({
canvasData: {
nodes: [sourceNode],
edges: [],
selectedNodeIds: ["image-1"],
viewport: { x: 0, y: 0, zoom: 1 },
},
plan: {
version: 1,
operationId: "op-append-1",
operation: "append_revision",
projectId: plannerContext.projectId || "",
sourceNodeIds: ["image-1"],
anchorNodeId: "image-1",
placement: "right",
mediaItems: [
{
mediaItemId: "media-append-1",
type: "image",
url: "https://cdn.example.com/revision.png",
title: "白底版本",
prompt: "换成白底",
metadata: {
messageId: "assistant-append-1",
model: "test-image-model",
},
},
],
},
});
expect(result.createdNodeIds).toHaveLength(1);
const resultCanvasData = result.canvasData as { nodes?: any[]; edges?: any[] };
expect(result.createdNodeIdsByMediaItemId["media-append-1"]).toBe(
result.createdNodeIds[0]
);
const createdNode = resultCanvasData.nodes?.find(
(node) => node.id === result.createdNodeIds[0]
);
expect(createdNode).toMatchObject({
type: "image",
data: {
src: "https://cdn.example.com/revision.png",
metadata: {
generatedByAgent: true,
canvasAgentOperationId: "op-append-1",
canvasAgentMediaItemId: "media-append-1",
revisionOf: "image-1",
sourceNodeId: "image-1",
sourceNodeIds: ["image-1"],
prompt: "换成白底",
model: "test-image-model",
messageId: "assistant-append-1",
sourceMessageId: "assistant-append-1",
sourceImageKey: "https://cdn.example.com/revision.png",
},
},
});
expect(createdNode?.position.x).toBeGreaterThan(sourceNode.position.x);
expect(resultCanvasData.nodes?.find((node) => node.id === "image-1")).toMatchObject({
data: { src: "https://cdn.example.com/source.png" },
});
expect(resultCanvasData.edges).toEqual([
expect.objectContaining({
source: "image-1",
target: result.createdNodeIds[0],
type: "reference",
}),
]);
});
test("fills agent slots and keeps operation retries idempotent", () => {
const slotNode = {
id: "slot-1",
type: "agent-slot",
position: { x: 400, y: 80 },
size: { width: 300, height: 300 },
data: {
slotType: "image",
status: "empty",
fillPolicy: "replace-empty",
filledNodeIds: [],
},
connections: [],
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
};
const plan = {
version: 1 as const,
operationId: "op-fill-slot-1",
operation: "fill_slot" as const,
projectId: plannerContext.projectId || "",
targetSlotId: "slot-1",
mediaItems: [
{
mediaItemId: "media-slot-1",
type: "image" as const,
url: "https://cdn.example.com/slot-result.png",
},
],
};
const first = applyCanvasOperationPlanToCanvasData({
canvasData: {
nodes: [slotNode],
edges: [],
selectedNodeIds: ["slot-1"],
viewport: { x: 0, y: 0, zoom: 1 },
},
plan,
});
expect(first.createdNodeIds).toHaveLength(1);
const firstCanvasData = first.canvasData as { nodes?: any[]; edges?: any[] };
expect(first.updatedNodeIds).toEqual(["slot-1"]);
expect(first.createdNodeIdsByMediaItemId).toEqual({
"media-slot-1": first.createdNodeIds[0],
});
expect(firstCanvasData.nodes?.find((node) => node.id === "slot-1")).toMatchObject({
data: {
status: "completed",
filledNodeIds: [first.createdNodeIds[0]],
},
});
expect(firstCanvasData.edges).toEqual([
expect.objectContaining({
source: "slot-1",
target: first.createdNodeIds[0],
}),
]);
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: first.canvasData,
plan,
});
expect(retry.createdNodeIds).toEqual(first.createdNodeIds);
expect(retry.createdNodeIdsByMediaItemId).toEqual(
first.createdNodeIdsByMediaItemId
);
expect((retry.canvasData as { nodes?: any[] }).nodes).toHaveLength(
firstCanvasData.nodes?.length || 0
);
});
test("applies batch media mutations with stable media item mappings", () => {
const anchorNode = {
id: "image-batch-source",
type: "image",
position: { x: 0, y: 0 },
size: { width: 200, height: 200 },
data: { src: "https://cdn.example.com/source.png", status: "completed" },
connections: [],
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
};
const result = applyCanvasOperationPlanToCanvasData({
canvasData: {
nodes: [anchorNode],
edges: [],
selectedNodeIds: ["image-batch-source"],
viewport: { x: 0, y: 0, zoom: 1 },
},
plan: {
version: 1,
operationId: "op-batch-1",
operation: "insert_beside",
projectId: plannerContext.projectId || "",
anchorNodeId: "image-batch-source",
placementStrategy: "grid",
mediaItems: [1, 2, 3].map((index) => ({
mediaItemId: `media-batch-${index}`,
type: "image" as const,
url: `https://cdn.example.com/result-${index}.png`,
})),
},
});
expect(result.createdNodeIds).toHaveLength(3);
const resultCanvasData = result.canvasData as { nodes?: any[]; edges?: any[] };
expect(Object.keys(result.createdNodeIdsByMediaItemId).sort()).toEqual([
"media-batch-1",
"media-batch-2",
"media-batch-3",
]);
expect(resultCanvasData.nodes).toHaveLength(4);
expect(resultCanvasData.edges).toHaveLength(3);
});
test("rejects stale confirmed high-risk media replacement mutations", () => {
const targetNode = {
id: "image-replace-1",
type: "image",
position: { x: 0, y: 0 },
size: { width: 200, height: 200 },
data: { src: "https://cdn.example.com/source.png", status: "completed" },
connections: [],
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
};
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData: {
nodes: [targetNode],
edges: [],
selectedNodeIds: ["image-replace-1"],
viewport: { x: 0, y: 0, zoom: 1 },
},
baseCanvasWasStale: true,
plan: {
version: 1,
operationId: "op-replace-1",
operation: "replace_node_media",
projectId: plannerContext.projectId || "",
targetNodeIds: ["image-replace-1"],
confirmed: true,
mediaItems: [
{
mediaItemId: "media-replace-1",
type: "image",
url: "https://cdn.example.com/replacement.png",
},
],
},
})
).toThrow(CanvasAgentMutationError);
});
test("keeps failed canvas operations eligible for canvasMedia fallback", () => {
const handled = new Set();
const failedHandledIds = getCanvasOperationHandledMediaItemIds({
status: "failed",
operationId: "op-1",
operation: "insert_beside",
handledCanvasMediaItemIds: ["media-1"],
mediaItemIds: ["media-1"],
fallbackToCanvasMedia: true,
});
failedHandledIds.forEach((id) => handled.add(id));
expect(failedHandledIds).toEqual([]);
expect(
shouldSkipCanvasMediaPayloadForHandledItems({
payload: {
mediaItemIds: ["media-1"],
imageUrls: ["https://cdn.example.com/a.png"],
},
handledCanvasMediaItemIds: handled,
})
).toBe(false);
const appliedHandledIds = getCanvasOperationHandledMediaItemIds({
status: "applied",
operationId: "op-2",
operation: "insert_beside",
handledCanvasMediaItemIds: ["media-2"],
});
appliedHandledIds.forEach((id) => handled.add(id));
expect(appliedHandledIds).toEqual(["media-2"]);
expect(
shouldSkipCanvasMediaPayloadForHandledItems({
payload: {
mediaItemIds: ["media-2"],
imageUrls: ["https://cdn.example.com/b.png"],
},
handledCanvasMediaItemIds: handled,
})
).toBe(true);
expect(
shouldSkipCanvasMediaPayloadForHandledItems({
payload: {
mediaItemIds: ["media-2", "media-3"],
imageUrls: ["https://cdn.example.com/b.png", "https://cdn.example.com/c.png"],
},
handledCanvasMediaItemIds: handled,
})
).toBe(false);
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-2", "media-3"],
imageUrls: ["https://cdn.example.com/b.png", "https://cdn.example.com/c.png"],
},
handledCanvasMediaItemIds: handled,
})
).toMatchObject({
mediaItemIds: ["media-3"],
imageUrls: ["https://cdn.example.com/c.png"],
videoUrls: [],
});
const shadowHandledIds = getCanvasOperationHandledMediaItemIds({
status: "shadowed",
operationId: "op-shadow",
operation: "insert_beside",
handledCanvasMediaItemIds: ["media-shadow"],
mediaItemIds: ["media-shadow"],
fallbackToCanvasMedia: true,
});
shadowHandledIds.forEach((id) => handled.add(id));
expect(shadowHandledIds).toEqual([]);
expect(
shouldSkipCanvasMediaPayloadForHandledItems({
payload: {
mediaItemIds: ["media-shadow"],
imageUrls: ["https://cdn.example.com/shadow.png"],
},
handledCanvasMediaItemIds: handled,
})
).toBe(false);
expect(
shouldSkipCanvasMediaPayloadForHandledItems({
payload: {
mediaItemIds: ["media-pending-replace"],
imageUrls: ["https://cdn.example.com/pending-replace.png"],
},
handledCanvasMediaItemIds: handled,
})
).toBe(false);
expect(
getCanvasMediaPayloadHandledItemIds({
handledCanvasMediaItemIds: [" media-4 ", "", "media-5"],
})
).toEqual(["media-4", "media-5"]);
});
test("filters handled items out of partially applied canvasMedia batches", () => {
const handled = new Set(["media-2"]);
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-1", "media-2", "media-3"],
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
"https://cdn.example.com/c.png",
],
prompt: "keep prompt",
},
handledCanvasMediaItemIds: handled,
})
).toMatchObject({
mediaItemIds: ["media-1", "media-3"],
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/c.png",
],
videoUrls: [],
prompt: "keep prompt",
});
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-2"],
imageUrls: ["https://cdn.example.com/b.png"],
},
handledCanvasMediaItemIds: handled,
})
).toMatchObject({
mediaItemIds: [],
imageUrls: [],
videoUrls: [],
});
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-image", "media-video"],
imageUrls: ["https://cdn.example.com/image.png"],
videoUrls: ["https://cdn.example.com/video.mp4"],
},
handledCanvasMediaItemIds: new Set(["media-image"]),
})
).toMatchObject({
mediaItemIds: ["media-video"],
imageUrls: [],
videoUrls: ["https://cdn.example.com/video.mp4"],
});
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-1", "media-2"],
imageUrls: ["https://cdn.example.com/a.png"],
},
handledCanvasMediaItemIds: handled,
})
).toMatchObject({
mediaItemIds: ["media-1", "media-2"],
imageUrls: ["https://cdn.example.com/a.png"],
});
});
test("keeps canvasMedia ids aligned with normalized batch media urls", () => {
expect(
normalizeCanvasMediaStreamPayload({
imageUrls: [
"not-a-url",
"https://cdn.example.com/a.png",
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
videoUrls: ["https://cdn.example.com/c.mp4"],
mediaItemIds: [
"media-invalid",
"media-a",
"media-a-duplicate",
"media-b",
"media-c",
],
handledCanvasMediaItemIds: [" handled-1 ", ""],
})
).toEqual({
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
videoUrls: ["https://cdn.example.com/c.mp4"],
mediaItemIds: ["media-a", "media-b", "media-c"],
handledCanvasMediaItemIds: ["handled-1"],
});
expect(
normalizeCanvasMediaStreamPayload({
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
mediaItemIds: ["only-one-id"],
})
).toEqual({
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
videoUrls: [],
mediaItemIds: [],
handledCanvasMediaItemIds: [],
});
});
test("writes applied, failed, and shadowed canvas operation stream chunks", () => {
const chunks: Array<{ type: string; data: string }> = [];
const writer = {
write(chunk: { type: string; data: string }) {
chunks.push(chunk);
},
};
writeCanvasOperationChunk(
writer,
{
projectId: "11111111-1111-4111-8111-111111111111",
operationId: "op-applied",
operation: "insert_beside",
createdNodeIds: ["node-1"],
createdNodeIdsByMediaItemId: { "media-1": "node-1" },
updatedNodeIds: [],
createdEdgeIds: ["edge-1"],
canvasRevision: "2026-06-22T00:00:00.000Z",
anchorNodeId: "anchor-1",
handledCanvasMediaItemIds: [" media-1 ", ""],
mediaItemIds: ["media-1"],
message: "已写回画布",
},
"test-applied"
);
writeCanvasOperationChunk(
writer,
{
projectId: "11111111-1111-4111-8111-111111111111",
operationId: "op-failed",
operation: "insert_beside",
status: "failed",
createdNodeIds: [],
updatedNodeIds: [],
createdEdgeIds: [],
handledCanvasMediaItemIds: ["should-not-be-used"],
mediaItemIds: [" media-2 ", ""],
errorCode: "STALE_CANVAS_REVISION",
errorMessage: "画布已更新",
fallbackToCanvasMedia: true,
},
"test-failed"
);
writeCanvasOperationChunk(
writer,
{
projectId: "11111111-1111-4111-8111-111111111111",
operationId: "op-shadowed",
operation: "insert_beside",
status: "shadowed",
createdNodeIds: [],
updatedNodeIds: [],
createdEdgeIds: [],
handledCanvasMediaItemIds: ["should-not-be-used"],
mediaItemIds: [" media-3 ", ""],
fallbackToCanvasMedia: true,
},
"test-shadowed"
);
expect(chunks).toHaveLength(3);
expect(chunks[0].type).toBe("data-canvasOperation");
expect(JSON.parse(chunks[0].data)).toMatchObject({
status: "applied",
operationId: "op-applied",
createdNodeIds: ["node-1"],
handledCanvasMediaItemIds: ["media-1"],
mediaItemIds: ["media-1"],
});
expect(JSON.parse(chunks[1].data)).toMatchObject({
status: "failed",
operationId: "op-failed",
handledCanvasMediaItemIds: [],
mediaItemIds: ["media-2"],
errorCode: "STALE_CANVAS_REVISION",
errorMessage: "画布已更新",
fallbackToCanvasMedia: true,
});
expect(JSON.parse(chunks[2].data)).toMatchObject({
status: "shadowed",
operationId: "op-shadowed",
handledCanvasMediaItemIds: [],
mediaItemIds: ["media-3"],
fallbackToCanvasMedia: true,
});
});
test("writes normalized canvasMedia stream chunks with shared media item ids", () => {
const chunks: Array<{ type: string; data: string }> = [];
const writer = {
write(chunk: { type: string; data: string }) {
chunks.push(chunk);
},
};
writeCanvasMediaChunk(
writer,
{
messageId: "message-1",
imageUrls: [
" https://cdn.example.com/a.png ",
"https://cdn.example.com/a.png",
"not-a-url",
],
videoUrls: ["https://cdn.example.com/a.mp4", ""],
prompt: " prompt ",
sourceImageUrls: [" https://cdn.example.com/source.png ", "bad-url"],
mediaItemIds: [" media-1 ", "", "media-2"],
handledCanvasMediaItemIds: [" handled-1 ", ""],
},
"test-canvas-media"
);
expect(chunks).toHaveLength(1);
expect(chunks[0].type).toBe("data-canvasMedia");
expect(JSON.parse(chunks[0].data)).toMatchObject({
messageId: "message-1",
imageUrls: ["https://cdn.example.com/a.png"],
videoUrls: ["https://cdn.example.com/a.mp4"],
prompt: "prompt",
sourceImageUrls: ["https://cdn.example.com/source.png"],
mediaItemIds: ["media-1", "media-2"],
handledCanvasMediaItemIds: ["handled-1"],
});
});
test("keeps agent transient viewport out of persisted canvas state", () => {
const store = useCanvasStore.getState();
store.clearCanvas();
store.setViewport({ x: 10, y: 20, zoom: 2 });
store.setTransientViewport({ x: 100, y: 200, zoom: 3 });
expect(useCanvasStore.getState().viewport).toEqual({ x: 100, y: 200, zoom: 3 });
expect(useCanvasStore.getState().getCanvasState().viewport).toEqual({
x: 10,
y: 20,
zoom: 2,
});
store.clearCanvas();
});
test("normalizes and interpolates canvas agent view animations", () => {
expect(normalizeCanvasAgentViewAnimationDuration(undefined)).toBe(280);
expect(normalizeCanvasAgentViewAnimationDuration(360)).toBe(360);
expect(normalizeCanvasAgentViewAnimationDuration(-20)).toBe(0);
expect(normalizeCanvasAgentViewAnimationDuration(1200)).toBe(900);
expect(easeOutCanvasAgentViewProgress(-1)).toBe(0);
expect(easeOutCanvasAgentViewProgress(1.5)).toBe(1);
expect(easeOutCanvasAgentViewProgress(0.5)).toBeCloseTo(0.875);
expect(
interpolateCanvasAgentViewport(
{ x: 0, y: 20, zoom: 1 },
{ x: 100, y: 220, zoom: 2 },
0
)
).toEqual({ x: 0, y: 20, zoom: 1 });
expect(
interpolateCanvasAgentViewport(
{ x: 0, y: 20, zoom: 1 },
{ x: 100, y: 220, zoom: 2 },
1
)
).toEqual({ x: 100, y: 220, zoom: 2 });
expect(
interpolateCanvasAgentViewport(
{ x: 0, y: 20, zoom: 1 },
{ x: 100, y: 220, zoom: 2 },
0.5
)
).toEqual({ x: 87.5, y: 195, zoom: 1.875 });
});
test("defers canvas agent view operations during active canvas interactions", () => {
expect(shouldDeferCanvasAgentViewOperation({ pointerDown: true })).toBe(true);
expect(shouldDeferCanvasAgentViewOperation({ activeEditable: true })).toBe(true);
expect(
shouldDeferCanvasAgentViewOperation({
lastPointerInteractionAt: 1000,
now: 1000 + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS - 1,
})
).toBe(true);
expect(
shouldDeferCanvasAgentViewOperation({
lastPointerInteractionAt: 1000,
now: 1000 + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS,
})
).toBe(false);
expect(shouldDeferCanvasAgentViewOperation({ now: 2000 })).toBe(false);
});
test("marks agent operation refresh windows for autosave guards", () => {
const store = useCanvasStore.getState();
store.clearAgentOperationRefresh();
expect(store.isAgentOperationRefreshActive()).toBe(false);
store.markAgentOperationRefresh(1000);
expect(useCanvasStore.getState().isAgentOperationRefreshActive()).toBe(true);
useCanvasStore.getState().clearAgentOperationRefresh();
expect(useCanvasStore.getState().isAgentOperationRefreshActive()).toBe(false);
});
test("queues target-based canvas view operations for post-refresh retry", () => {
expect(
shouldRetryCanvasViewOperationAfterRefresh({
version: 1,
viewOperationId: "view-result",
operation: "focus_latest_result",
nodeIds: ["new-image-1"],
})
).toBe(true);
expect(
shouldRetryCanvasViewOperationAfterRefresh({
version: 1,
viewOperationId: "view-fit-all",
operation: "fit_all",
})
).toBe(true);
expect(
shouldRetryCanvasViewOperationAfterRefresh({
version: 1,
viewOperationId: "view-zoom",
operation: "zoom_in",
})
).toBe(false);
expect(
shouldRetryCanvasViewOperationAfterRefresh(
{
version: 1,
viewOperationId: "view-other-project",
operation: "focus_latest_result",
projectId: "project-other",
nodeIds: ["new-image-1"],
},
"project-current"
)
).toBe(false);
});
test("builds transient view operations for agent writeback results", () => {
expect(
buildCanvasAgentResultViewOperation({
projectId: plannerContext.projectId,
operationId: "operation-1",
mutationOperation: "insert_beside",
createdNodeIds: ["new-image-1"],
})
).toMatchObject({
version: 1,
viewOperationId: "operation-1:view",
operation: "focus_latest_result",
nodeIds: ["new-image-1"],
anchorNodeId: "new-image-1",
persist: false,
reason: "focus_canvas_agent_writeback_result",
});
expect(
buildCanvasAgentResultViewOperation({
projectId: plannerContext.projectId,
operationId: "operation-2",
mutationOperation: "append_revision",
createdNodeIds: ["new-image-1", "new-image-2", "new-image-1"],
updatedNodeIds: ["slot-1"],
})
).toMatchObject({
operation: "focus_nodes",
nodeIds: ["new-image-1", "new-image-2"],
anchorNodeId: "new-image-1",
});
expect(
buildCanvasAgentResultViewOperation({
projectId: plannerContext.projectId,
operationId: "operation-compare",
mutationOperation: "append_revision",
sourceNodeIds: ["image-1", " image-1 "],
createdNodeIds: ["new-image-1"],
anchorNodeId: "new-image-1",
})
).toMatchObject({
operation: "focus_nodes",
nodeIds: ["image-1", "new-image-1"],
anchorNodeId: "image-1",
reason: "focus_canvas_agent_writeback_comparison",
});
expect(
buildCanvasAgentResultViewOperation({
projectId: plannerContext.projectId,
operationId: "operation-3",
mutationOperation: "replace_node_media",
updatedNodeIds: ["image-1"],
})
).toMatchObject({
operation: "focus_node",
nodeIds: ["image-1"],
reason: "focus_canvas_agent_replaced_nodes",
});
expect(
buildCanvasAgentResultViewOperation({
projectId: plannerContext.projectId,
operationId: "operation-4",
})
).toBeNull();
});
test("resolves comparison view targets from selection and revision metadata", () => {
const comparisonContext: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: ["agent-result-1"],
selectedNodes: [
{
nodeId: "agent-result-1",
type: "image",
title: "白底版本",
x: 460,
y: 20,
width: 300,
height: 240,
isSelected: true,
metadata: {
generatedByAgent: true,
revisionOf: "image-1",
sourceNodeIds: ["image-1"],
},
},
],
candidateNodes: [
{
nodeId: "image-1",
type: "image",
title: "原图",
x: 10,
y: 20,
width: 300,
height: 240,
},
],
};
expect(hasCanvasAgentComparisonViewIntent("对比原图和新图")).toBe(true);
expect(
resolveCanvasAgentComparisonNodeIds({
context: comparisonContext,
})
).toEqual(["image-1", "agent-result-1"]);
expect(
buildCanvasAgentComparisonViewPlan({
context: comparisonContext,
projectId: plannerContext.projectId || "",
operationId: "operation-compare",
})
).toMatchObject({
operation: "focus_nodes",
nodeIds: ["image-1", "agent-result-1"],
anchorNodeId: "image-1",
persist: false,
reason: "focus_canvas_comparison_targets",
});
expect(
resolveCanvasAgentComparisonNodeIds({
context: {
...comparisonContext,
selectedNodeIds: ["image-1", "agent-result-1"],
selectedNodes: [
{
nodeId: "image-1",
type: "image",
x: 10,
y: 20,
isSelected: true,
},
{
nodeId: "agent-result-1",
type: "image",
x: 460,
y: 20,
isSelected: true,
metadata: { generatedByAgent: true, revisionOf: "image-1" },
},
],
candidateNodes: [],
},
})
).toEqual(["image-1", "agent-result-1"]);
});
test("builds latest-result view plans for follow-up focus requests", () => {
expect(hasCanvasAgentLatestResultViewIntent("聚焦刚生成结果")).toBe(true);
expect(hasCanvasAgentLatestResultViewIntent("放大看看最新结果")).toBe(true);
expect(hasCanvasAgentLatestResultViewIntent("生成一张新图")).toBe(false);
expect(
buildCanvasAgentLatestResultViewPlan({
userText: "帮我定位到刚生成的图",
projectId: plannerContext.projectId || "",
operationId: "operation-latest",
})
).toMatchObject({
viewOperationId: "operation-latest:view",
operation: "focus_latest_result",
projectId: plannerContext.projectId,
persist: false,
reason: "focus_latest_canvas_agent_result",
});
});
test("resolves latest-result and pan canvas view operations", () => {
expect(
resolveCanvasViewOperationTargetNodeIds({
plan: {
operation: "focus_latest_result",
},
selectedNodeIds: ["selected-1"],
nodes: [
{
id: "old-agent-result",
data: {
metadata: {
generatedByAgent: true,
},
},
createdAt: "2026-06-22T00:00:00.000Z",
},
{
id: "manual-node",
data: {
metadata: {},
},
createdAt: "2026-06-22T00:02:00.000Z",
},
{
id: "new-agent-result",
data: {
metadata: {
canvasAgentOperationId: "op-1",
},
},
updatedAt: "2026-06-22T00:03:00.000Z",
},
],
})
).toEqual(["new-agent-result", "old-agent-result"]);
expect(
resolveCanvasViewOperationTargetNodeIds({
plan: {
operation: "focus_selection",
},
selectedNodeIds: ["selected-1"],
nodes: [],
})
).toEqual(["selected-1"]);
expect(
computeCanvasAgentPanViewport({
currentViewport: { x: 10, y: 20, zoom: 1 },
center: { x: 100, y: 50 },
stage: { width: 1000, height: 800 },
zoom: 2,
})
).toEqual({
x: 320,
y: 268,
zoom: 2,
});
});
test("builds relative pan view plans from canvas viewport context", () => {
expect(hasCanvasAgentPanViewIntent("右边那张图")).toBe(false);
expect(hasCanvasAgentPanViewIntent("向右移动视角一点")).toBe(true);
const plan = buildCanvasAgentPanViewPlan({
userText: "向右移动视角一点",
context: {
...plannerContext,
viewport: { x: -200, y: -100, zoom: 2, width: 1000, height: 800 },
},
projectId: plannerContext.projectId || "",
operationId: "view-pan",
});
expect(plan).toMatchObject({
version: 1,
viewOperationId: "view-pan:view",
operation: "pan_to",
projectId: plannerContext.projectId,
center: {
x: 490,
y: 234,
},
zoom: 2,
persist: false,
reason: "pan_canvas_view",
});
const diagonalPlan = buildCanvasAgentPanViewPlan({
userText: "把画布往左上拖一点",
context: {
...plannerContext,
viewport: { x: -200, y: -100, zoom: 2, width: 1000, height: 800 },
},
projectId: plannerContext.projectId || "",
operationId: "view-pan-diagonal",
});
expect(diagonalPlan?.center?.x).toBeCloseTo(230);
expect(diagonalPlan?.center?.y).toBeCloseTo(130);
});
test("resolves canvas view operation plans into transient viewport targets", () => {
const zoomIn = resolveCanvasViewOperation({
plan: {
version: 1,
viewOperationId: "view-zoom-in",
operation: "zoom_in",
},
nodes: [],
currentViewport: { x: 10, y: 20, zoom: 2 },
stage: { width: 1000, height: 800 },
});
expect(zoomIn?.targetNodeIds).toEqual([]);
expect(zoomIn?.viewport.zoom).toBeCloseTo(2.44);
expect(zoomIn?.viewport.x).toBeCloseTo(-102.2);
expect(zoomIn?.viewport.y).toBeCloseTo(-56.56);
const focusSelection = resolveCanvasViewOperation({
plan: {
version: 1,
viewOperationId: "view-focus-selection",
operation: "focus_selection",
},
nodes: [
{
id: "image-1",
type: "image",
position: { x: 100, y: 120 },
size: { width: 320, height: 240 },
},
],
selectedNodeIds: ["image-1"],
currentViewport: { x: 0, y: 0, zoom: 1 },
stage: { width: 1000, height: 800 },
});
expect(focusSelection?.targetNodeIds).toEqual(["image-1"]);
expect(focusSelection?.viewport.x).toBeCloseTo(218.4);
expect(focusSelection?.viewport.y).toBeCloseTo(89.6);
expect(focusSelection?.viewport.zoom).toBeCloseTo(1.16);
const fitAll = resolveCanvasViewOperation({
plan: {
version: 1,
viewOperationId: "view-fit-all",
operation: "fit_all",
padding: 100,
},
nodes: [
{
id: "image-1",
type: "image",
position: { x: 0, y: 0 },
size: { width: 200, height: 100 },
},
{
id: "text-1",
type: "text",
position: { x: 500, y: 200 },
size: { width: 100, height: 100 },
},
{
id: "workflow-1",
type: "workflow",
position: { x: 2000, y: 2000 },
size: { width: 400, height: 400 },
},
],
currentViewport: { x: 0, y: 0, zoom: 1 },
stage: { width: 1000, height: 800 },
});
expect(fitAll?.targetNodeIds).toEqual([]);
expect(fitAll?.viewport.zoom).toBeCloseTo(0.1784615385);
expect(fitAll?.viewport.x).toBeCloseTo(305.8461538);
expect(fitAll?.viewport.y).toBeCloseTo(153.8461538);
const panTo = resolveCanvasViewOperation({
plan: {
version: 1,
viewOperationId: "view-pan-to",
operation: "pan_to",
center: { x: 490, y: 234 },
zoom: 2,
},
nodes: [],
currentViewport: { x: -200, y: -100, zoom: 2 },
stage: { width: 1000, height: 800 },
});
expect(panTo?.targetNodeIds).toEqual([]);
expect(panTo?.viewport).toEqual({
x: -460,
y: -100,
zoom: 2,
});
});
test("resolves grid media placements to the right of an anchor node", () => {
const placements = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-grid",
operation: "insert_beside",
projectId: "project-1",
anchorNodeId: "anchor-1",
placement: "right",
placementStrategy: "grid",
},
mediaItems: [
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
{ mediaItemId: "media-3", type: "image", url: "https://cdn.example.com/3.png", width: 100, height: 80 },
],
nodes: [
{
id: "anchor-1",
position: { x: 10, y: 20 },
size: { width: 300, height: 200 },
},
],
});
expect(placements.map((placement) => placement.position)).toEqual([
{ x: 350, y: 20 },
{ x: 474, y: 20 },
{ x: 350, y: 124 },
]);
expect(placements.map((placement) => placement.edgeSourceId)).toEqual([
"anchor-1",
"anchor-1",
"anchor-1",
]);
});
test("resolves batch media beside each item source node", () => {
const placements = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-source-batch",
operation: "insert_beside",
projectId: "project-1",
placement: "right",
placementStrategy: "beside_each_source",
},
mediaItems: [
{
mediaItemId: "media-a-1",
type: "image",
url: "https://cdn.example.com/a1.png",
width: 20,
height: 20,
sourceNodeIds: ["source-a"],
},
{
mediaItemId: "media-b-1",
type: "image",
url: "https://cdn.example.com/b1.png",
width: 20,
height: 20,
sourceNodeIds: ["source-b"],
},
{
mediaItemId: "media-a-2",
type: "image",
url: "https://cdn.example.com/a2.png",
width: 20,
height: 20,
sourceNodeIds: ["source-a"],
},
],
nodes: [
{
id: "source-a",
position: { x: 0, y: 0 },
size: { width: 50, height: 50 },
},
{
id: "source-b",
position: { x: 1000, y: 10 },
size: { width: 50, height: 50 },
},
],
});
expect(placements.map((placement) => placement.position)).toEqual([
{ x: 90, y: 0 },
{ x: 1090, y: 10 },
{ x: 134, y: 0 },
]);
expect(placements.map((placement) => placement.edgeSourceId)).toEqual([
"source-a",
"source-b",
"source-a",
]);
expect(placements[0]?.sourceNodeIds).toEqual(["source-a"]);
});
test("resolves overlay and viewport-centered media placements", () => {
const [overlay] = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-overlay",
operation: "append_revision",
projectId: "project-1",
anchorNodeId: "source-1",
placementStrategy: "overlay",
},
mediaItems: [
{
mediaItemId: "media-overlay",
type: "image",
url: "https://cdn.example.com/overlay.png",
},
],
nodes: [
{
id: "source-1",
position: { x: 120, y: 80 },
size: { width: 160, height: 90 },
},
],
});
expect(overlay?.position).toEqual({ x: 120, y: 80 });
expect(overlay?.size).toEqual({ width: 160, height: 90 });
const centeredPlacements = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-center",
operation: "insert_beside",
projectId: "project-1",
anchorNodeId: "source-1",
placement: "viewport-center",
placementStrategy: "grid",
},
mediaItems: [
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
],
nodes: [
{
id: "source-1",
position: { x: 1000, y: 1000 },
size: { width: 200, height: 200 },
},
],
viewport: { x: -200, y: -100, zoom: 1 },
});
expect(centeredPlacements.map((placement) => placement.position)).toEqual([
{ x: 88, y: 60 },
{ x: 212, y: 60 },
]);
const viewportAwarePlacements = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-center-with-stage",
operation: "insert_beside",
projectId: "project-1",
placement: "viewport-center",
placementStrategy: "grid",
metadata: {
canvasAgentViewport: {
x: -200,
y: -100,
zoom: 1,
width: 1000,
height: 800,
},
},
},
mediaItems: [
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
],
nodes: [],
viewport: { x: 0, y: 0, zoom: 1 },
});
expect(viewportAwarePlacements.map((placement) => placement.position)).toEqual([
{ x: 588, y: 460 },
{ x: 712, y: 460 },
]);
});
test("normalizes slot-only canvas agent context without image nodes", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["slot-1"],
canvasImageNodes: [],
canvasAgentContext: {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["slot-1"],
selectedNodes: [
{
nodeId: "slot-1",
type: "agent-slot",
x: 120,
y: 80,
width: 320,
height: 240,
isSelected: true,
},
],
candidateNodes: [],
slots: [
{
nodeId: "slot-1",
slotType: "image",
x: 120,
y: 80,
width: 320,
height: 240,
aspectRatio: "4:3",
isSelected: true,
fillPolicy: "replace-empty",
},
],
annotations: [],
viewport: { x: 0, y: 0, zoom: 1 },
focusPolicy: "slot",
},
});
expect(context?.selectedNodeIds).toEqual(["slot-1"]);
expect(context?.slots).toHaveLength(1);
expect(context?.candidateNodes).toEqual([]);
expect(context?.focusPolicy).toBe("slot");
});
test("bypasses missing-target clarification for selected agent slot generation", () => {
const slotOnlyContext = {
source: "canvas" as const,
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["slot-1"],
canvasImageNodes: [],
canvasAgentContext: {
version: 1 as const,
source: "canvas" as const,
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["slot-1"],
selectedNodes: [
{
nodeId: "slot-1",
type: "agent-slot" as const,
x: 120,
y: 80,
width: 320,
height: 240,
isSelected: true,
},
],
candidateNodes: [],
slots: [
{
nodeId: "slot-1",
slotType: "image" as const,
x: 120,
y: 80,
width: 320,
height: 240,
aspectRatio: "4:3",
isSelected: true,
fillPolicy: "replace-empty" as const,
},
],
annotations: [],
viewport: { x: 0, y: 0, zoom: 1 },
focusPolicy: "slot" as const,
},
};
expect(
shouldBypassCanvasAgentMissingTargetClarification({
text: "在这里生成一张电商主图",
contextPayload: slotOnlyContext,
executionMode: "generate_image",
})
).toMatchObject({
shouldBypass: true,
reason: "selected_canvas_agent_slot_generation_intent",
});
expect(
shouldBypassCanvasAgentMissingTargetClarification({
text: "把这张图背景换白",
contextPayload: slotOnlyContext,
executionMode: "edit_image",
}).shouldBypass
).toBe(false);
expect(
shouldBypassCanvasAgentMissingTargetClarification({
text: "在这里生成一张电商主图",
contextPayload: {
...slotOnlyContext,
selectedNodeIds: [],
canvasAgentContext: {
...slotOnlyContext.canvasAgentContext,
selectedNodeIds: [],
slots: [{ ...slotOnlyContext.canvasAgentContext.slots[0], isSelected: false }],
},
},
executionMode: "generate_image",
}).shouldBypass
).toBe(false);
});
test("keeps view-only canvas agent context without nodes or media", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: [],
canvasImageNodes: [],
canvasAgentContext: {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: [],
selectedNodes: [],
candidateNodes: [],
slots: [],
annotations: [],
viewport: { x: -240, y: -120, zoom: 0.8, width: 1440, height: 900 },
focusPolicy: "auto",
},
});
expect(context).toMatchObject({
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodes: [],
candidateNodes: [],
slots: [],
viewport: { x: -240, y: -120, zoom: 0.8, width: 1440, height: 900 },
focusPolicy: "auto",
});
});
test("clips oversized canvas agent context lists to protocol limits", () => {
const candidateNodes = Array.from({ length: 42 }, (_, index) => ({
nodeId: `image-${index + 1}`,
type: "image",
url: `https://cdn.example.com/${index + 1}.png`,
x: index * 10,
y: index * 5,
isSelected: index < 25,
}));
const slots = Array.from({ length: 30 }, (_, index) => ({
nodeId: `slot-${index + 1}`,
slotType: "image",
x: index,
y: index,
width: 320,
height: 320,
aspectRatio: "1:1",
fillPolicy: "replace-empty",
}));
const annotations = Array.from({ length: 30 }, (_, index) => ({
id: `annotation-${index + 1}`,
sourceNodeId: "image-1",
type: "text-note",
text: `批注 ${index + 1}`,
}));
const context = normalizeCanvasAgentContext({
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: candidateNodes.map((node) => node.nodeId),
canvasAgentContext: {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: candidateNodes.map((node) => node.nodeId),
selectedNodes: candidateNodes,
candidateNodes,
slots,
annotations,
viewport: { x: 0, y: 0, zoom: 1 },
focusPolicy: "selected",
},
});
expect(context?.selectedNodeIds).toHaveLength(20);
expect(context?.selectedNodes).toHaveLength(20);
expect(context?.candidateNodes).toHaveLength(30);
expect(context?.slots).toHaveLength(20);
expect(context?.annotations).toHaveLength(20);
});
test("normalizes canvas agent annotations from node metadata", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["image-1"],
canvasAgentContext: {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["image-1"],
selectedNodes: [
{
nodeId: "image-1",
type: "image",
x: 0,
y: 0,
isSelected: true,
},
],
candidateNodes: [],
slots: [],
annotations: [
{
id: "annotation-1",
sourceNodeId: "image-1",
type: "text-note",
text: "保留产品轮廓",
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
},
],
viewport: { x: 0, y: 0, zoom: 1 },
focusPolicy: "selected",
},
});
expect(context?.annotations).toEqual([
{
id: "annotation-1",
sourceNodeId: "image-1",
type: "text-note",
text: "保留产品轮廓",
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
targetPoint: undefined,
color: undefined,
createdAt: undefined,
},
]);
});
test("keeps pending canvas agent UI requests until the matching panel event consumes them", () => {
const targetWindow = {} as CanvasAgentPendingRequestWindow;
const slotRequest = {
nodeId: "slot-1",
label: "主图槽",
};
const annotationRequest = {
nodeId: "image-annotated",
title: "白底主图",
annotationCount: 2,
};
storeCanvasAgentSlotFillRequest(targetWindow, slotRequest);
storeCanvasAgentAnnotationEditRequest(targetWindow, annotationRequest);
expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest);
expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toEqual(
annotationRequest
);
expect(
consumeCanvasAgentSlotFillRequest(targetWindow, { nodeId: "other-slot" })
).toEqual(slotRequest);
expect(
consumeCanvasAgentAnnotationEditRequest(targetWindow, {
nodeId: "other-image",
})
).toEqual(annotationRequest);
expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest);
expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toEqual(
annotationRequest
);
expect(
consumeCanvasAgentSlotFillRequest(targetWindow, { nodeId: "slot-1" })
).toEqual(slotRequest);
expect(
consumeCanvasAgentAnnotationEditRequest(targetWindow, {
nodeId: "image-annotated",
})
).toEqual(annotationRequest);
expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toBeNull();
expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toBeNull();
});
test("consumes pending canvas agent UI requests when no event detail is provided", () => {
const targetWindow = {} as CanvasAgentPendingRequestWindow;
const slotRequest = {
nodeId: "slot-1",
label: "主图槽",
};
const annotationRequest = {
nodeId: "image-annotated",
title: "白底主图",
annotationCount: 2,
};
storeCanvasAgentSlotFillRequest(targetWindow, slotRequest);
storeCanvasAgentAnnotationEditRequest(targetWindow, annotationRequest);
expect(consumeCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest);
expect(consumeCanvasAgentAnnotationEditRequest(targetWindow)).toEqual(
annotationRequest
);
expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toBeNull();
expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toBeNull();
});
test("keeps pending annotation edit targets eligible for the next canvas context", () => {
const nodes = [
{
id: "image-annotated",
type: "image",
position: { x: 10, y: 20 },
size: { width: 300, height: 240 },
data: {
src: "https://cdn.example.com/annotated.png",
metadata: {
annotations: [
{
id: "annotation-1",
type: "text-note",
text: "背景换浅灰",
},
],
},
},
},
{
id: "text-1",
type: "text",
position: { x: 500, y: 20 },
size: { width: 220, height: 80 },
data: { content: "说明" },
},
] as any[];
const pendingNode = resolvePendingCanvasAgentTargetNode({
nodes,
pendingTarget: {
nodeId: "image-annotated",
requestedAt: 1000,
},
now: 1500,
isEligibleNode: (node) => node.type === "image",
});
expect(pendingNode?.id).toBe("image-annotated");
expect(
resolvePendingCanvasAgentTargetNode({
nodes,
pendingTarget: {
nodeId: "image-annotated",
requestedAt: 1000,
},
now: 1000 + 10 * 60 * 1000 + 1,
isEligibleNode: (node) => node.type === "image",
})
).toBeNull();
expect(
resolvePendingCanvasAgentTargetNode({
nodes,
pendingTarget: {
nodeId: "text-1",
requestedAt: 1000,
},
now: 1500,
isEligibleNode: (node) => node.type === "image",
})
).toBeNull();
});
test("merges pending annotation edit targets into selected node ids without duplicates", () => {
expect(
mergePendingCanvasAgentTargetNodeIds({
selectedNodeIds: ["image-1", "image-2"],
pendingTargetNodeId: "image-1",
})
).toEqual(["image-1", "image-2"]);
expect(
mergePendingCanvasAgentTargetNodeIds({
selectedNodeIds: ["image-1"],
pendingTargetNodeId: "image-annotated",
})
).toEqual(["image-1", "image-annotated"]);
expect(
mergePendingCanvasAgentTargetNodeIds({
selectedNodeIds: Array.from({ length: 20 }, (_, index) => `node-${index + 1}`),
pendingTargetNodeId: "node-21",
maxItems: 20,
})
).toHaveLength(20);
});
test("builds annotation-aware edit prompts only for annotation requests", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: ["image-1"],
selectedNodes: [
{
nodeId: "image-1",
type: "image",
title: "主图",
x: 10,
y: 20,
width: 300,
height: 240,
isSelected: true,
},
],
annotations: [
{
id: "annotation-1",
sourceNodeId: "image-1",
type: "box",
text: "把这里的水印去掉",
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
},
{
id: "annotation-2",
sourceNodeId: "image-2",
type: "text-note",
text: "不应进入 image-1 的提示",
},
],
};
expect(hasCanvasAgentAnnotationEditIntent("按批注修改这张图")).toBe(true);
expect(hasCanvasAgentAnnotationEditIntent("批注里的水印去掉")).toBe(true);
expect(hasCanvasAgentAnnotationEditIntent("把背景换成白色")).toBe(false);
expect(hasCanvasAgentAnnotationEditIntent("给选中图片加批注:这里保留")).toBe(
false
);
const prompt = buildCanvasAgentAnnotationEditPrompt({
context,
targetNodeIds: ["image-1"],
userText: "按批注修改这张图",
});
expect(prompt).toContain("【画布批注编辑要求】");
expect(prompt).toContain("把这里的水印去掉");
expect(prompt).toContain("框选区域:x=0.1, y=0.2, width=0.3, height=0.4");
expect(prompt).not.toContain("不应进入 image-1 的提示");
expect(
buildCanvasAgentAnnotationEditPrompt({
context,
targetNodeIds: ["image-1"],
userText: "把背景换成白色",
})
).toBe("");
expect(
buildCanvasAgentAnnotationEditPrompt({
context,
targetNodeIds: ["image-1"],
userText: "给选中图片加批注:这里保留",
})
).toBe("");
});
test("labels image note action as an agent annotation entry", () => {
expect(
getCanvasToolbarItem(defaultCanvasToolbarConfig, "imageNote")
).toMatchObject({
label: "添加批注",
enabled: true,
});
});
test("syncs image notes into structured canvas annotations", () => {
const first = syncCanvasNodeNoteAnnotationMetadata({
metadata: {
keep: true,
annotations: [
{
id: "agent-annotation-1",
sourceNodeId: "image-1",
type: "box",
text: "保留这个框选区域",
createdByAgent: true,
},
],
},
nodeId: "image-1",
note: " 让 Agent 按这个备注改主图 ",
now: "2026-06-22T00:00:00.000Z",
});
expect(first.noteAnnotationId).toBe("image-node-note:image-1");
expect(first.hasStructuredAnnotations).toBe(true);
expect(first.metadata.keep).toBe(true);
expect(first.annotations).toHaveLength(2);
expect(first.annotations.at(-1)).toMatchObject({
id: "image-node-note:image-1",
source: CANVAS_NODE_NOTE_ANNOTATION_SOURCE,
sourceNodeId: "image-1",
type: "text-note",
text: "让 Agent 按这个备注改主图",
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
createdByUser: true,
});
const updated = syncCanvasNodeNoteAnnotationMetadata({
metadata: first.metadata,
nodeId: "image-1",
note: "换成蓝色背景",
now: "2026-06-22T00:01:00.000Z",
});
expect(updated.annotations).toHaveLength(2);
expect(updated.annotations.at(-1)).toMatchObject({
id: "image-node-note:image-1",
text: "换成蓝色背景",
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:01:00.000Z",
});
const cleared = syncCanvasNodeNoteAnnotationMetadata({
metadata: updated.metadata,
nodeId: "image-1",
note: "",
now: "2026-06-22T00:02:00.000Z",
});
expect(cleared.hasStructuredAnnotations).toBe(true);
expect(cleared.annotations).toEqual([
{
id: "agent-annotation-1",
sourceNodeId: "image-1",
type: "box",
text: "保留这个框选区域",
createdByAgent: true,
},
]);
expect(cleared.metadata.annotationsUpdatedAt).toBe("2026-06-22T00:02:00.000Z");
});
test("maps visual image annotations into structured canvas metadata", () => {
const result = appendCanvasVisualAnnotationsMetadata({
metadata: {
annotations: [
{
id: "note-1",
sourceNodeId: "image-1",
type: "text-note",
text: "已有备注",
},
],
},
nodeId: "image-1",
imageWidth: 200,
imageHeight: 100,
now: "2026-06-22T00:03:00.000Z",
actions: [
{
type: "circle",
color: "#ff0000",
startX: 20,
startY: 30,
radius: 10,
},
{
type: "arrow",
color: "#00ff00",
startX: 0,
startY: 0,
endX: 100,
endY: 50,
},
{
type: "text",
color: "#0000ff",
startX: 10,
startY: 20,
text: "去掉这里",
},
],
});
expect(result.appendedAnnotations).toHaveLength(3);
expect(result.annotations).toHaveLength(4);
expect(result.appendedAnnotations[0]).toMatchObject({
source: CANVAS_VISUAL_ANNOTATION_SOURCE,
sourceNodeId: "image-1",
type: "box",
text: "用户圈选了需要关注或修改的区域",
bounds: { x: 0.05, y: 0.2, width: 0.1, height: 0.2 },
visualActionType: "circle",
createdByUser: true,
});
expect(result.appendedAnnotations[1]).toMatchObject({
type: "arrow-note",
targetPoint: { x: 0.5, y: 0.5 },
visualActionType: "arrow",
});
expect(result.appendedAnnotations[2]).toMatchObject({
type: "text-note",
text: "去掉这里",
targetPoint: { x: 0.05, y: 0.2 },
visualActionType: "text",
});
expect(result.metadata.annotationsUpdatedAt).toBe("2026-06-22T00:03:00.000Z");
});
test("syncs local edit regions into crop-box canvas annotations", () => {
const first = syncCanvasLocalEditRegionAnnotationsMetadata({
metadata: {
annotations: [
{
id: "note-1",
sourceNodeId: "image-1",
type: "text-note",
text: "保留的备注",
},
],
},
nodeId: "image-1",
now: "2026-06-22T00:04:00.000Z",
regions: [
{
id: "region-1",
index: 1,
color: "#ef4444",
prompt: "把框内 logo 去掉",
rect: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
},
{
id: "region-2",
index: 2,
color: "#06b6d4",
prompt: "比例调整",
rect: { x: 0.2, y: 0.3, width: 0.2, height: 0.2 },
targetRect: { x: 0.25, y: 0.35, width: 0.3, height: 0.25 },
},
],
});
expect(first.regionAnnotations).toHaveLength(2);
expect(first.annotations).toHaveLength(3);
expect(first.regionAnnotations[0]).toMatchObject({
id: "image-local-edit-region:image-1:region-1",
source: CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE,
sourceNodeId: "image-1",
type: "crop-box",
text: "把框内 logo 去掉",
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
color: "#ef4444",
createdByUser: true,
localEditRegionId: "region-1",
});
expect(first.regionAnnotations[1].text).toContain("目标区域:x=0.25");
const cleared = syncCanvasLocalEditRegionAnnotationsMetadata({
metadata: first.metadata,
nodeId: "image-1",
now: "2026-06-22T00:05:00.000Z",
regions: [],
});
expect(cleared.regionAnnotations).toHaveLength(0);
expect(cleared.annotations).toEqual([
{
id: "note-1",
sourceNodeId: "image-1",
type: "text-note",
text: "保留的备注",
},
]);
});
test("normalizes selected text-only canvas agent context", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["text-1"],
canvasImageNodes: [],
canvasAgentContext: {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["text-1"],
selectedNodes: [
{
nodeId: "text-1",
type: "text",
title: "旧标题",
x: 40,
y: 80,
width: 240,
height: 80,
isSelected: true,
},
],
candidateNodes: [],
slots: [],
annotations: [],
viewport: { x: 0, y: 0, zoom: 1 },
focusPolicy: "selected",
},
});
expect(context?.selectedNodeIds).toEqual(["text-1"]);
expect(context?.selectedNodes[0]).toMatchObject({
nodeId: "text-1",
type: "text",
title: "旧标题",
});
expect(context?.focusPolicy).toBe("selected");
});
test("builds canvas agent context from legacy canvas image nodes", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
selectedNodeIds: ["image-1"],
canvasImageNodes: [
{
nodeId: "image-1",
url: "https://cdn.example.com/image.png",
title: "主图",
x: 10,
y: 20,
width: 300,
height: 300,
isSelected: true,
},
],
});
expect(context?.selectedNodes).toHaveLength(1);
expect(context?.candidateNodes[0]).toMatchObject({
nodeId: "image-1",
type: "image",
url: "https://cdn.example.com/image.png",
isSelected: true,
});
});
test("normalizes media urls without query strings or trailing slashes", () => {
expect(normalizeMediaUrl(" HTTPS://EXAMPLE.COM/assets/image.png?sig=secret#preview ")).toBe(
"https://example.com/assets/image.png"
);
expect(normalizeMediaUrl("not-a-url?cache=1#hash/")).toBe("not-a-url");
});
test("collects message image urls from parts, attachments, and legacy content", () => {
const message = {
id: "message-1",
role: "user",
parts: [
{
type: "file",
url: "https://cdn.example.com/upload-a.png?x=1",
mediaType: "image/png",
},
{
type: "text",
text: "参考  和 https://cdn.example.com/plain-a.jpg?token=1",
},
],
attachments: [
{
name: "attachment",
url: "https://cdn.example.com/attachment-a.jpeg?download=1",
contentType: "image/jpeg",
},
],
content: "legacy https://cdn.example.com/legacy-a.png",
} as unknown as ChatMessage;
expect(collectMessageImageUrls(message)).toEqual([
"https://cdn.example.com/upload-a.png?x=1",
"https://cdn.example.com/markdown-a.webp",
"https://cdn.example.com/plain-a.jpg?token=1",
"https://cdn.example.com/attachment-a.jpeg?download=1",
"https://cdn.example.com/legacy-a.png",
]);
});
test("filters assistant result images that match nearest user references", () => {
const referenceKeys = new Set(["https://cdn.example.com/reference.png"]);
const urls = extractAssistantImageUrls(
[
"",
"",
].join("\n"),
referenceKeys
);
expect(urls).toEqual(["https://cdn.example.com/result.png?token=secret"]);
});
test("does not append Claw images that already render inside markdown tables", () => {
const markdown = [
"| 商品 | 主图 |",
"| --- | --- |",
"| A |  |",
"| B | [查看](https://cdn.example.com/table-link.webp?token=secret) |",
"",
"补充图片:",
"",
].join("\n");
const imageUrls = extractAssistantImageUrls(markdown);
expect(getMarkdownTableImageUrlKeys(markdown)).toEqual(
new Set([
"https://cdn.example.com/table-image.png",
"https://cdn.example.com/table-link.webp",
])
);
expect(filterStandaloneAssistantImageUrls(markdown, imageUrls)).toEqual([
"https://cdn.example.com/standalone-image.jpg?token=secret",
]);
});
test("extracts and strips video markup without removing surrounding text", () => {
const text = [
"生成完成",
'',
"[下载视频](https://cdn.example.com/video-b.mp4)",
"继续优化",
].join("\n");
expect(extractVideoUrls(text)).toEqual([
"https://cdn.example.com/video-a.mp4?sig=1",
"https://cdn.example.com/video-b.mp4",
]);
expect(stripMediaMarkup(text)).toBe("生成完成\n\n\n继续优化");
});
test("extracts playable audio and common video formats from Claw markdown", () => {
const text = [
"任务完成",
"[播放音频](https://cdn.example.com/result?id=1&format=mp3)",
'',
"[播放视频](https://cdn.example.com/result-video?filename=demo.mp4)",
"https://cdn.example.com/preview.webm?token=3",
"查看说明:https://cdn.example.com/readme.html",
].join("\n");
expect(extractAudioUrls(text)).toEqual([
"https://cdn.example.com/result?id=1&format=mp3",
"https://cdn.example.com/voice.m4a?sig=2",
]);
expect(extractVideoUrls(text)).toEqual([
"https://cdn.example.com/result-video?filename=demo.mp4",
"https://cdn.example.com/preview.webm?token=3",
]);
expect(stripMediaMarkup(text)).toContain(
"查看说明:https://cdn.example.com/readme.html"
);
expect(stripMediaMarkup(text)).not.toContain("播放音频");
expect(stripMediaMarkup(text)).not.toContain("preview.webm");
});
test("builds detail review plans with clamped count and localized fallbacks", () => {
const plans = buildDetailReviewPlans({
language: "zh",
count: 9,
summary: " 已验证卖点 ",
brief: {
productSpu: "",
demandType: "",
productName: "无线吸尘器",
targetAudience: "养宠家庭",
mainUser: "",
usageScenario: "客厅清洁",
triggerMoment: "",
coreNeed: "快速清理毛发",
riskConcern: "吸力不足",
expectedResult: "地面干净",
coreSellingPoint: "强吸力,轻量,低噪音",
positioningStatement: "",
userVoice: "",
competitorReference: "",
priceBand: "",
brandTone: "",
bannedWords: "",
},
});
expect(plans).toHaveLength(5);
expect(plans[0]).toMatchObject({
index: 1,
title: "真实需求反馈1",
});
expect(plans[0]?.reviewText).toContain("养宠家庭");
expect(plans[0]?.reviewText).toContain("已验证卖点");
expect(plans[0]?.imagePrompt).toContain("无线吸尘器");
});
test("keeps detail slot indexes and generation mode options stable", () => {
expect(DETAIL_TARGET_WIDTH).toBe(790);
expect(DETAIL_TARGET_HEIGHT).toBe(1300);
expect(DETAIL_SLOT_CONFIGS.map((slot) => slot.role)).toEqual([
"product",
"product",
"product",
"product",
"product",
"model",
"bundle",
"bundle",
"bundle",
]);
expect(DETAIL_PRODUCT_SLOT_INDEXES).toEqual([0, 1, 2, 3, 4]);
expect(DETAIL_MODEL_SLOT_INDEX).toBe(5);
expect(DETAIL_BUNDLE_SLOT_INDEXES).toEqual([6, 7, 8]);
expect(DETAIL_GENERATION_MODE_OPTIONS.map((option) => option.value)).toEqual([
"classic",
"agent_layout",
]);
});
test("normalizes detail config parser helpers defensively", () => {
expect(normalizeDetailGenerationMode("agent_layout")).toBe("agent_layout");
expect(normalizeDetailGenerationMode("unknown")).toBe("classic");
expect(extractDetailFrameCount("需要 23 张详情页")).toBe(20);
expect(extractDetailFrameCount("需要 2 屏")).toBe(5);
expect(extractDetailFrameCount("没有明确数量")).toBe(15);
expect(extractFirstNumber("标题 64px / 700", 32)).toBe(64);
expect(extractFirstNumber("无数字", 32)).toBe(32);
expect(extractFontWeight("主标题 64px / 700 / 行高1.15", "600")).toBe("700");
expect(extractFontWeight("weight: 500", "600")).toBe("500");
expect(extractFontWeight("无权重", "600")).toBe("600");
expect(splitDetailKeywords("强吸力,轻量; 低噪音 | 易清洁", 3)).toEqual([
"强吸力",
"轻量",
"低噪音",
]);
});
test("sanitizes detail image model prompts away from pixel-locked layout instructions", () => {
const sanitized = sanitizeDetailImageModelPrompt(
[
"字体规范:主标题 64px / 700 / 行高1.15",
"输出为竖版电商详情图,尺寸固定 790x1300 像素。",
"安全区 y=64~360,左右边距56px,模块间距24px。",
"商业摄影质感,留出清晰文案排版区,不要水印。",
"突出产品真实材质与使用场景。",
].join("\n")
);
expect(sanitized).not.toContain("790x1300");
expect(sanitized).not.toContain("64px");
expect(sanitized).not.toContain("56px");
expect(sanitized).toContain("输出为竖版电商详情图。");
expect(sanitized).toContain("顶部标题安全区");
expect(sanitized).toContain("左右留白充足");
expect(sanitized).toContain("突出产品真实材质与使用场景。");
});
test("builds stable detail result and appended review markdown", () => {
const markdown = buildDetailResultMarkdown({
generationMode: "agent_layout",
summary: "详情页生成成功",
images: [
{
originalUrl: "https://cdn.example.com/detail-1.png",
thumbnailUrl: "https://cdn.example.com/detail-1-thumb.webp",
},
],
prompts: ["商业摄影质感"],
skillFlow: {
name: "详情页生成",
version: "1.0",
steps: ["拆解卖点", "生成分镜"],
},
qualityChecks: {
passed: true,
},
screens: [
{
screenType: "A",
title: "核心卖点",
useModel: true,
fabe: {
b: "收益",
},
},
],
reviewGroups: [
{
reviewText: "真实好用",
images: [
{
originalUrl: "https://cdn.example.com/review-1.png",
thumbnailUrl: "https://cdn.example.com/review-1-thumb.webp",
},
],
},
],
});
expect(markdown).toContain("生成路线:AGENT排版(画面+可编辑文字层)");
expect(markdown).toContain("质检结果:通过");
expect(markdown).toContain("![thumb|||https://cdn.example.com/detail-1.png|||2K]");
expect(markdown).toContain("评价 1:真实好用");
expect(
buildDetailReviewAppendMarkdown({
failedCount: 1,
groups: [
{
reviewText: "追加评价",
images: [
{
originalUrl: "https://cdn.example.com/review-2.png",
thumbnailUrl: "https://cdn.example.com/review-2-thumb.webp",
},
],
},
],
})
).toContain("失败 1 条,已跳过。");
});
test("sanitizes and sorts detail archives from persisted payloads", () => {
const archives = sanitizeDetailArchiveList([
{
id: "older",
title: "",
prompt: " old prompt ",
generationMode: "invalid",
brief: {
productName: "旧产品",
targetAudience: "人群",
},
slots: [
{
url: "https://cdn.example.com/product.png",
name: "",
contentType: "",
},
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
uiSurfaceLatestReview: {
screenIndex: 2,
action: "unsafe",
passed: true,
issues: [" issue-a ", "", "issue-b"],
},
},
{
id: "newer",
title: "新项目",
prompt: "new prompt",
generationMode: "agent_layout",
brief: {
coreNeed: "核心需求",
},
slots: [],
createdAt: "2026-01-03T00:00:00.000Z",
},
{
id: "",
createdAt: "2026-01-04T00:00:00.000Z",
},
]);
expect(archives.map((archive) => archive.id)).toEqual(["newer", "older"]);
expect(archives[0]?.generationMode).toBe("agent_layout");
expect(archives[1]).toMatchObject({
title: "未命名详情项目",
generationMode: "classic",
brief: {
productName: "旧产品",
coreNeed: "",
},
});
expect(archives[1]?.slots[0]).toMatchObject({
url: "https://cdn.example.com/product.png",
name: "产品图1",
contentType: "image/jpeg",
});
expect(archives[1]?.uiSurfaceLatestReview).toMatchObject({
screenIndex: 2,
action: undefined,
passed: true,
issues: ["issue-a", "issue-b"],
});
});
test("creates and updates detail archive lists with stable limits", () => {
const archive = createDetailArchiveSnapshot({
archiveId: "draft",
createdAt: 1000,
prompt: " 第一行\n第二行 ",
generationMode: "classic",
summary: "摘要",
imageCount: 2,
coverUrl: "https://cdn.example.com/cover.png",
brief: {
productSpu: " SPU-001 ",
demandType: "",
productName: "产品名",
targetAudience: "",
mainUser: "",
usageScenario: " 客厅清洁 ",
triggerMoment: "",
coreNeed: "",
riskConcern: "",
expectedResult: "",
coreSellingPoint: "",
positioningStatement: "",
userVoice: "",
competitorReference: "",
priceBand: "",
brandTone: "",
bannedWords: "",
},
slots: [
{
name: "产品",
url: "https://cdn.example.com/product.png",
contentType: "image/png",
},
null,
],
});
expect(archive).toMatchObject({
id: "draft",
title: "SPU-001+客厅清洁",
prompt: "第一行 第二行",
createdAt: 1000,
updatedAt: 1000,
summary: "摘要",
imageCount: 2,
coverUrl: "https://cdn.example.com/cover.png",
});
expect(archive.slots[0]).toMatchObject({
name: "产品",
url: "https://cdn.example.com/product.png",
contentType: "image/png",
});
const patched = patchDetailArchiveList([archive], "draft", { summary: "新摘要" }, 2000);
expect(patched[0]).toMatchObject({ summary: "新摘要", updatedAt: 2000 });
const saved = { ...archive, id: "saved", updatedAt: 3000 };
expect(replaceSavedDetailArchive([archive], "draft", saved).map((item) => item.id)).toEqual([
"saved",
]);
const manyArchives = Array.from({ length: 35 }, (_, index) => ({
...archive,
id: `archive-${index}`,
createdAt: index,
updatedAt: index,
}));
expect(prependDetailArchive(manyArchives, saved)).toHaveLength(30);
});
test("fills detail slots by current role group without crossing groups", () => {
expect(getDetailSlotTargetIndexes(0)).toEqual([0, 1, 2, 3, 4]);
expect(getDetailSlotTargetIndexes(3)).toEqual([3, 4]);
expect(getDetailSlotTargetIndexes(5)).toEqual([5]);
expect(getDetailSlotTargetIndexes(7)).toEqual([7, 8]);
expect(getDetailSlotTargetIndexes(-1)).toEqual([]);
expect(getDetailSlotTargetIndexes(99)).toEqual([]);
const current = Array.from({ length: DETAIL_SLOT_CONFIGS.length }, () => null);
const productA: PickedObjectAttachment = {
name: "product-a",
url: "https://cdn.example.com/product-a.png",
contentType: "image/png",
};
const productB: PickedObjectAttachment = {
name: "product-b",
url: "https://cdn.example.com/product-b.png",
contentType: "image/png",
};
const updatedSingle = applyDetailSlotAttachment(current, 5, productA);
expect(updatedSingle[5]).toBe(productA);
expect(current[5]).toBeNull();
const filled = fillDetailSlotAttachments(current, getDetailSlotTargetIndexes(3), [
productA,
productB,
]);
expect(filled.filledCount).toBe(2);
expect(filled.slots[3]).toBe(productA);
expect(filled.slots[4]).toBe(productB);
expect(filled.slots[5]).toBeNull();
});
test("validates and normalizes canvas image upload helper payloads", () => {
expect(validateCanvasImageUploadFile(null)).toEqual({
ok: false,
message: "请选择图片文件",
});
expect(
validateCanvasImageUploadFile({
name: "demo.txt",
type: "text/plain",
size: 10,
})
).toEqual({
ok: false,
message: "请上传图片文件",
});
expect(
validateCanvasImageUploadFile({
name: "huge.png",
type: "image/png",
size: CANVAS_IMAGE_UPLOAD_MAX_BYTES + 1,
})
).toEqual({
ok: false,
message: "图片大小不能超过 50MB",
});
expect(
validateCanvasImageUploadFile({
name: "valid.webp",
type: "image/webp",
size: CANVAS_IMAGE_UPLOAD_MAX_BYTES,
})
).toEqual({ ok: true });
expect(getCanvasUploadImageTitle(" product.hero.final.png ")).toBe("product.hero.final");
expect(getCanvasUploadImageTitle(".png")).toBe("图片");
expect(normalizeUploadedAssetId(" asset-123 ")).toBe("asset-123");
expect(normalizeUploadedAssetId(" ")).toBeUndefined();
expect(
buildCanvasUploadedImageAttachment({
file: {
name: "photo.png",
type: "",
size: 100,
},
imageUrl: "https://cdn.example.com/photo.png",
assetId: "asset-1",
})
).toEqual({
name: "photo.png",
url: "https://cdn.example.com/photo.png",
contentType: "image/jpeg",
assetId: "asset-1",
});
});
});