ai-chatbot/tests/routes/canvas-agent-operations.test.ts

1621 lines
46 KiB
TypeScript

import { expect, test } from "@playwright/test";
import {
canvasAgentOperationRequestSchema,
parseCanvasAgentOperationRequestBody,
} from "@/lib/canvas/agent-operation-request";
import {
applyCanvasOperationPlanToCanvasData,
CanvasAgentMutationError,
type CanvasOperationResult,
} from "@/lib/canvas/agent-mutations";
import type { CanvasAgentFeatureFlags } from "@/lib/canvas/agent-feature-flags";
import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
const projectId = "11111111-1111-4111-8111-111111111111";
const enabledRouteFeatures: CanvasAgentFeatureFlags = {
operationProtocolEnabled: true,
writebackMode: "operation_authoritative",
slotEnabled: true,
viewOperationEnabled: true,
annotationEditEnabled: true,
};
function buildMediaItem(index = 1) {
return {
mediaItemId: `media-${index}`,
type: "image" as const,
url: `https://example.com/generated-${index}.png`,
assetId: "22222222-2222-4222-8222-222222222222",
width: 1024,
height: 768,
title: `generated-${index}`,
prompt: "make the product cleaner",
sourceNodeIds: ["image-1"],
metadata: {
provider: "test",
},
};
}
function buildPlan(overrides: Record<string, unknown>) {
return {
version: 1,
operationId: "message-1:canvas-agent:test",
projectId,
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
...overrides,
};
}
function parsePlan(overrides: Record<string, unknown>) {
return parseCanvasAgentOperationRequestBody({
operationPlan: buildPlan(overrides),
});
}
function issuePathsFor(overrides: Record<string, unknown>) {
const result = canvasAgentOperationRequestSchema.safeParse({
operationPlan: buildPlan(overrides),
});
expect(result.success).toBe(false);
return result.success
? []
: result.error.issues.map((issue) => issue.path.join("."));
}
function buildImageNode(id = "image-1") {
return {
id,
type: "image",
position: { x: 100, y: 120 },
size: { width: 320, height: 240 },
data: {
src: `https://cdn.example.com/${id}.png`,
status: "completed",
title: id,
},
connections: [],
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
};
}
function buildTextNode(id = "text-1") {
return {
id,
type: "text",
position: { x: 160, y: 180 },
size: { width: 280, height: 120 },
data: {
content: "旧文案",
fontSize: 18,
color: "#111827",
},
connections: [],
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
};
}
function buildAgentSlotNode(id = "slot-1", slotType: "image" | "video" = "image") {
return {
id,
type: "agent-slot",
position: { x: 500, y: 120 },
size: { width: 300, height: 300 },
data: {
slotType,
status: "empty",
fillPolicy: "replace-empty",
filledNodeIds: [],
},
connections: [],
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
};
}
function buildMutationMediaItem(
mediaItemId = "media-result-1",
sourceNodeIds?: string[]
) {
return {
mediaItemId,
type: "image" as const,
url: `https://cdn.example.com/${mediaItemId}.png`,
width: 1024,
height: 768,
title: mediaItemId,
prompt: "make a clean revision",
sourceNodeIds,
};
}
function getCanvasData(nodes: any[]) {
return {
nodes,
edges: [],
selectedNodeIds: nodes.length > 0 ? [nodes[0].id] : [],
viewport: { x: 0, y: 0, zoom: 1 },
};
}
function getResultNodes(canvasData: unknown) {
const nodes = (canvasData as { nodes?: any[] }).nodes;
return Array.isArray(nodes) ? nodes : [];
}
function getResultEdges(canvasData: unknown) {
const edges = (canvasData as { edges?: any[] }).edges;
return Array.isArray(edges) ? edges : [];
}
function buildRouteSuccessResult(): CanvasOperationResult {
return {
createdNodeIds: ["node-created-1"],
createdNodeIdsByMediaItemId: { "media-1": "node-created-1" },
updatedNodeIds: [],
createdEdgeIds: ["edge-1"],
canvasData: getCanvasData([]),
canvasRevision: "2026-06-22T00:01:00.000Z",
canvasUpdatedAt: "2026-06-22T00:01:00.000Z",
baseCanvasWasStale: false,
};
}
function buildRouteRequestBody(overrides: Record<string, unknown>) {
return {
operationPlan: buildPlan(overrides),
};
}
test.describe("canvas agent operation request schema", () => {
test("accepts slot fill requests with batch media and revision guards", () => {
const body = parsePlan({
operation: "fill_slot",
targetSlotId: "slot-1",
placement: "viewport-center",
preserveOriginal: true,
mediaItems: [buildMediaItem(1), buildMediaItem(2)],
});
expect(body.operationPlan).toMatchObject({
operation: "fill_slot",
targetSlotId: "slot-1",
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
mediaItems: [
{ mediaItemId: "media-1", url: "https://example.com/generated-1.png" },
{ mediaItemId: "media-2", url: "https://example.com/generated-2.png" },
],
});
});
test("accepts append revision requests with legacy media payloads", () => {
const body = parsePlan({
operation: "append_revision",
sourceNodeIds: ["image-1"],
targetNodeIds: ["image-1"],
media: {
type: "image",
url: "https://example.com/legacy.png",
width: 1200,
height: 900,
title: "legacy-result",
},
});
expect(body.operationPlan).toMatchObject({
operation: "append_revision",
media: {
url: "https://example.com/legacy.png",
},
});
});
test("accepts slot-only, annotation-only, and text-only operations", () => {
expect(
parsePlan({
operation: "create_agent_slot",
targetPosition: { x: 120, y: 160 },
slot: {
slotType: "image",
label: "主图结果槽",
aspectRatio: "4:3",
fillPolicy: "replace-empty",
acceptedSources: ["text", "image", "annotation"],
},
}).operationPlan.operation
).toBe("create_agent_slot");
expect(
parsePlan({
operation: "add_annotation",
targetNodeIds: ["image-1"],
annotations: [
{
id: "annotation-1",
sourceNodeId: "image-1",
type: "box",
text: "tighten crop",
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
color: "#ff3366",
},
],
}).operationPlan.operation
).toBe("add_annotation");
expect(
parsePlan({
operation: "update_text",
targetNodeIds: ["text-1"],
textContent: "New canvas title",
}).operationPlan.operation
).toBe("update_text");
expect(
parsePlan({
operation: "create_text_node",
targetPosition: { x: 120, y: 160 },
textContent: "New canvas copy",
}).operationPlan.operation
).toBe("create_text_node");
});
test("caps batch media at twenty items", () => {
const accepted = parsePlan({
operation: "insert_beside",
sourceNodeIds: ["image-1"],
placementStrategy: "grid",
mediaItems: Array.from({ length: 20 }, (_, index) => buildMediaItem(index + 1)),
});
expect(accepted.operationPlan.mediaItems).toHaveLength(20);
expect(
issuePathsFor({
operation: "insert_beside",
sourceNodeIds: ["image-1"],
mediaItems: Array.from({ length: 21 }, (_, index) => buildMediaItem(index + 1)),
})
).toContain("operationPlan.mediaItems");
});
test("rejects media operations without generated media", () => {
expect(
issuePathsFor({
operation: "insert_beside",
sourceNodeIds: ["image-1"],
placement: "right",
})
).toContain("operationPlan.mediaItems");
});
test("rejects annotation and text operations without required content", () => {
expect(
issuePathsFor({
operation: "add_annotation",
targetNodeIds: ["image-1"],
})
).toContain("operationPlan.annotations");
expect(
issuePathsFor({
operation: "update_text",
targetNodeIds: ["text-1"],
})
).toContain("operationPlan.textContent");
expect(
issuePathsFor({
operation: "rename_node",
targetNodeIds: ["image-1"],
})
).toContain("operationPlan.textContent");
expect(
issuePathsFor({
operation: "update_note",
targetNodeIds: ["image-1"],
})
).toContain("operationPlan.textContent");
expect(
issuePathsFor({
operation: "create_text_node",
})
).toContain("operationPlan.textContent");
});
test("rejects invalid media urls", () => {
expect(
issuePathsFor({
operation: "replace_node_media",
targetNodeIds: ["image-1"],
mediaItems: [
{
...buildMediaItem(),
url: "not-a-url",
},
],
})
).toContain("operationPlan.mediaItems.0.url");
expect(
issuePathsFor({
operation: "insert_beside",
anchorNodeId: "image-1",
mediaItems: [
{
...buildMediaItem(),
url: "ftp://example.com/generated.png",
},
],
})
).toContain("operationPlan.mediaItems.0.url");
expect(
issuePathsFor({
operation: "append_revision",
sourceNodeIds: ["image-1"],
media: {
type: "image",
url: "file:///tmp/generated.png",
},
})
).toContain("operationPlan.media.url");
});
});
test.describe("canvas agent operation route handler", () => {
test("returns 401 without reading the body when the user is not authenticated", async () => {
let bodyRead = false;
let operationApplied = false;
const response = await handleCanvasAgentOperationRouteRequest({
userId: null,
projectId,
readBody: async () => {
bodyRead = true;
return {};
},
features: enabledRouteFeatures,
applyOperation: async () => {
operationApplied = true;
return buildRouteSuccessResult();
},
});
expect(response).toMatchObject({
status: 401,
body: { success: false, error: "未登录" },
});
expect(bodyRead).toBe(false);
expect(operationApplied).toBe(false);
});
test("returns rollout protection before parsing body when mutations are disabled", async () => {
let bodyRead = false;
let operationApplied = false;
const response = await handleCanvasAgentOperationRouteRequest({
userId: "user-1",
projectId,
readBody: async () => {
bodyRead = true;
return {};
},
features: {
...enabledRouteFeatures,
writebackMode: "operation_shadow",
},
applyOperation: async () => {
operationApplied = true;
return buildRouteSuccessResult();
},
});
expect(response).toMatchObject({
status: 409,
body: {
success: false,
code: "CANVAS_AGENT_WRITEBACK_DISABLED",
writebackMode: "operation_shadow",
},
});
expect(bodyRead).toBe(false);
expect(operationApplied).toBe(false);
});
test("applies valid requests with authenticated user and project id", async () => {
const calls: Array<{
userId: string;
projectId: string;
planProjectId?: string;
operationId: string;
}> = [];
const expectedResult = buildRouteSuccessResult();
const response = await handleCanvasAgentOperationRouteRequest({
userId: "user-1",
projectId,
rawBody: buildRouteRequestBody({
operation: "insert_beside",
anchorNodeId: "image-1",
mediaItems: [buildMediaItem(1)],
}),
features: enabledRouteFeatures,
applyOperation: async (input) => {
calls.push({
userId: input.userId,
projectId: input.projectId,
planProjectId: input.plan.projectId,
operationId: input.plan.operationId,
});
return expectedResult;
},
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
if (response.body.success) {
expect(response.body.data).toBe(expectedResult);
}
expect(calls).toEqual([
{
userId: "user-1",
projectId,
planProjectId: projectId,
operationId: "message-1:canvas-agent:test",
},
]);
});
test("fills missing operation plan project id from route project id", async () => {
let appliedPlanProjectId = "";
const response = await handleCanvasAgentOperationRouteRequest({
userId: "user-1",
projectId,
rawBody: buildRouteRequestBody({
projectId: undefined,
operation: "insert_beside",
anchorNodeId: "image-1",
mediaItems: [buildMediaItem(1)],
}),
features: enabledRouteFeatures,
applyOperation: async (input) => {
appliedPlanProjectId = String(input.plan.projectId || "");
return buildRouteSuccessResult();
},
});
expect(response.status).toBe(200);
expect(appliedPlanProjectId).toBe(projectId);
});
test("rejects operation plans for a different project before applying", async () => {
let operationApplied = false;
const response = await handleCanvasAgentOperationRouteRequest({
userId: "user-1",
projectId,
rawBody: buildRouteRequestBody({
projectId: "33333333-3333-4333-8333-333333333333",
operation: "insert_beside",
anchorNodeId: "image-1",
mediaItems: [buildMediaItem(1)],
}),
features: enabledRouteFeatures,
applyOperation: async () => {
operationApplied = true;
return buildRouteSuccessResult();
},
});
expect(response).toMatchObject({
status: 400,
body: {
success: false,
code: "CANVAS_AGENT_PROJECT_MISMATCH",
},
});
expect(operationApplied).toBe(false);
});
test("maps invalid bodies and mutation errors to API responses", async () => {
let operationApplied = false;
const invalidResponse = await handleCanvasAgentOperationRouteRequest({
userId: "user-1",
projectId,
rawBody: { operationPlan: { operation: "insert_beside" } },
features: enabledRouteFeatures,
applyOperation: async () => {
operationApplied = true;
return buildRouteSuccessResult();
},
});
expect(invalidResponse.status).toBe(400);
expect(invalidResponse.body).toMatchObject({
success: false,
error: "请求参数无效",
});
expect(operationApplied).toBe(false);
const invalidJsonResponse = await handleCanvasAgentOperationRouteRequest({
userId: "user-1",
projectId,
readBody: async () => {
throw new SyntaxError("Unexpected end of JSON input");
},
features: enabledRouteFeatures,
applyOperation: async () => {
operationApplied = true;
return buildRouteSuccessResult();
},
});
expect(invalidJsonResponse).toMatchObject({
status: 400,
body: { success: false, error: "请求 JSON 无效" },
});
expect(operationApplied).toBe(false);
const mutationResponse = await handleCanvasAgentOperationRouteRequest({
userId: "user-1",
projectId,
rawBody: buildRouteRequestBody({
operation: "insert_beside",
anchorNodeId: "missing-anchor",
mediaItems: [buildMediaItem(1)],
}),
features: enabledRouteFeatures,
applyOperation: async () => {
throw new CanvasAgentMutationError(
"锚点节点不存在",
400,
"CANVAS_AGENT_NODE_NOT_FOUND"
);
},
});
expect(mutationResponse).toMatchObject({
status: 400,
body: {
success: false,
error: "锚点节点不存在",
code: "CANVAS_AGENT_NODE_NOT_FOUND",
},
});
});
});
test.describe("canvas agent operation mutation contract", () => {
test("rejects explicit anchor or source references that are not in canvas data", () => {
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([]),
plan: {
version: 1,
operationId: "op-missing-anchor",
operation: "insert_beside",
projectId,
anchorNodeId: "missing-anchor",
mediaItems: [buildMutationMediaItem()],
},
})
).toThrow(/锚点节点不存在/);
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([buildImageNode()]),
plan: {
version: 1,
operationId: "op-missing-source",
operation: "append_revision",
projectId,
sourceNodeIds: ["missing-source"],
mediaItems: [buildMutationMediaItem()],
},
})
).toThrow(/来源节点不存在/);
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([buildImageNode()]),
plan: {
version: 1,
operationId: "op-missing-media-source",
operation: "insert_beside",
projectId,
mediaItems: [
buildMutationMediaItem("media-result-1", ["missing-media-source"]),
],
},
})
).toThrow(/媒体来源节点不存在/);
});
test("creates agent slot nodes with stable metadata and idempotency", () => {
const canvasData = {
...getCanvasData([]),
viewport: { x: -120, y: -80, zoom: 2, width: 1000, height: 700 },
};
const plan: CanvasOperationPlan = {
version: 1 as const,
operationId: "op-create-video-slot",
operation: "create_agent_slot" as const,
projectId,
targetPosition: { x: 240, y: 180 },
slot: {
slotType: "video" as const,
label: "视频结果槽",
aspectRatio: "16:9",
fillPolicy: "append-version" as const,
acceptedSources: ["text", "image", "annotation"],
},
metadata: {
messageId: "assistant-message-1",
chatId: "chat-1",
prompt: "创建一个视频槽位",
},
};
const first = applyCanvasOperationPlanToCanvasData({
canvasData,
plan,
});
const createdSlotId = first.createdNodeIds[0];
const createdSlot = getResultNodes(first.canvasData).find(
(node) => node.id === createdSlotId
);
expect(createdSlot).toMatchObject({
type: "agent-slot",
position: { x: 240, y: 180 },
size: { width: 360, height: 220 },
data: {
slotType: "video",
label: "视频结果槽",
status: "empty",
aspectRatio: "16:9",
fillPolicy: "append-version",
acceptedSources: ["text", "image", "annotation"],
filledNodeIds: [],
metadata: {
createdByAgent: true,
canvasAgentOperationId: "op-create-video-slot",
canvasAgentOperation: "create_agent_slot",
messageId: "assistant-message-1",
chatId: "chat-1",
prompt: "创建一个视频槽位",
},
},
});
expect((first.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([
createdSlotId,
]);
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: first.canvasData,
plan,
});
expect(retry.createdNodeIds).toEqual(first.createdNodeIds);
expect(getResultNodes(retry.canvasData)).toHaveLength(
getResultNodes(first.canvasData).length
);
});
test("creates text nodes from agent plans with trace metadata and idempotency", () => {
const canvasData = {
...getCanvasData([]),
viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 700 },
};
const plan: CanvasOperationPlan = {
version: 1,
operationId: "op-create-text-node",
operation: "create_text_node",
projectId,
textContent: "618 限时优惠\n新品主推",
metadata: {
messageId: "assistant-message-text",
chatId: "chat-1",
prompt: "在画布上添加一段促销文案",
},
};
const first = applyCanvasOperationPlanToCanvasData({
canvasData,
plan,
});
const createdTextId = first.createdNodeIds[0];
const createdText = getResultNodes(first.canvasData).find(
(node) => node.id === createdTextId
);
expect(createdText).toMatchObject({
type: "text",
position: { x: 570, y: 392 },
data: {
content: "618 限时优惠\n新品主推",
fontSize: 18,
color: "#111827",
metadata: {
createdByAgent: true,
canvasAgentOperationId: "op-create-text-node",
canvasAgentOperation: "create_text_node",
messageId: "assistant-message-text",
chatId: "chat-1",
prompt: "在画布上添加一段促销文案",
},
},
});
expect((first.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([
createdTextId,
]);
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: first.canvasData,
plan,
});
expect(retry.createdNodeIds).toEqual(first.createdNodeIds);
expect(getResultNodes(retry.canvasData)).toHaveLength(
getResultNodes(first.canvasData).length
);
});
test("updates selected text nodes with trace metadata and idempotency", () => {
const plan: CanvasOperationPlan = {
version: 1,
operationId: "op-update-text-node",
operation: "update_text",
projectId,
targetNodeIds: ["text-update"],
textContent: "轻盈强吸力,家务更省心",
metadata: {
messageId: "assistant-message-update-text",
chatId: "chat-1",
},
};
const first = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([buildTextNode("text-update")]),
plan,
});
const updatedText = getResultNodes(first.canvasData).find(
(node) => node.id === "text-update"
);
expect(first.createdNodeIds).toEqual([]);
expect(first.updatedNodeIds).toEqual(["text-update"]);
expect(updatedText).toMatchObject({
data: {
content: "轻盈强吸力,家务更省心",
metadata: {
updatedByAgent: true,
canvasAgentOperationId: "op-update-text-node",
canvasAgentOperation: "update_text",
previousContent: "旧文案",
messageId: "assistant-message-update-text",
chatId: "chat-1",
},
},
});
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: first.canvasData,
plan,
});
const retryText = getResultNodes(retry.canvasData).find(
(node) => node.id === "text-update"
);
expect(retry.updatedNodeIds).toEqual(["text-update"]);
expect(retryText.data.metadata.previousContent).toBe("旧文案");
});
test("links selected canvas nodes without duplicating existing edges", () => {
const source = buildImageNode("image-link-source");
const target = buildTextNode("text-link-target");
const plan: CanvasOperationPlan = {
version: 1,
operationId: "op-link-nodes",
operation: "link_nodes",
projectId,
sourceNodeIds: ["image-link-source", "text-link-target"],
edgeType: "reference",
metadata: {
messageId: "assistant-message-link",
chatId: "chat-1",
},
};
const first = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([source, target]),
plan,
});
expect(first.createdNodeIds).toEqual([]);
expect(first.updatedNodeIds).toEqual([
"image-link-source",
"text-link-target",
]);
expect(first.createdEdgeIds).toHaveLength(1);
expect(getResultEdges(first.canvasData)).toEqual([
expect.objectContaining({
source: "image-link-source",
target: "text-link-target",
type: "reference",
}),
]);
expect(getResultNodes(first.canvasData)).toEqual([
expect.objectContaining({
id: "image-link-source",
connections: ["text-link-target"],
}),
expect.objectContaining({
id: "text-link-target",
connections: ["image-link-source"],
}),
]);
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: first.canvasData,
plan,
});
expect(retry.createdEdgeIds).toEqual([]);
expect(getResultEdges(retry.canvasData)).toHaveLength(1);
});
test("fills an agent slot and returns media item node mappings", () => {
const slot = buildAgentSlotNode();
const result = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([slot]),
plan: {
version: 1,
operationId: "op-fill-slot",
operation: "fill_slot",
projectId,
targetSlotId: "slot-1",
mediaItems: [buildMutationMediaItem("media-slot-result")],
},
});
expect(result.createdNodeIds).toHaveLength(1);
expect(result.createdNodeIdsByMediaItemId).toEqual({
"media-slot-result": result.createdNodeIds[0],
});
expect(result.updatedNodeIds).toEqual(["slot-1"]);
expect(getResultNodes(result.canvasData).find((node) => node.id === "slot-1"))
.toMatchObject({
data: {
status: "completed",
filledNodeIds: [result.createdNodeIds[0]],
},
});
expect(getResultEdges(result.canvasData)).toEqual([
expect.objectContaining({
source: "slot-1",
target: result.createdNodeIds[0],
}),
]);
});
test("rejects fill-slot media when it does not match the slot type", () => {
const videoSlot = buildAgentSlotNode("video-slot-1", "video");
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([videoSlot]),
plan: {
version: 1,
operationId: "op-fill-video-slot-with-image",
operation: "fill_slot",
projectId,
targetSlotId: "video-slot-1",
mediaItems: [buildMutationMediaItem("media-image-result")],
},
})
).toThrow("目标 slot 是视频槽位,不能填充图片结果");
const imageSlot = buildAgentSlotNode("image-slot-1", "image");
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([imageSlot]),
plan: {
version: 1,
operationId: "op-fill-image-slot-with-video",
operation: "fill_slot",
projectId,
targetSlotId: "image-slot-1",
mediaItems: [
{
mediaItemId: "media-video-result",
type: "video",
url: "https://cdn.example.com/media-video-result.mp4",
},
],
},
})
).toThrow("目标 slot 是图片槽位,不能填充视频结果");
});
test("confirms replace-existing slot fills and keeps retries idempotent", () => {
const slot = buildAgentSlotNode("slot-replace-existing") as any;
slot.data = {
...slot.data,
fillPolicy: "replace-existing",
filledNodeIds: ["old-slot-result"],
};
const oldResult = {
...buildImageNode("old-slot-result"),
position: { x: 500, y: 120 },
};
const unrelated = buildImageNode("unrelated-image");
const canvasData = {
...getCanvasData([slot, oldResult, unrelated]),
edges: [
{
id: "edge-slot-old-result",
source: "slot-replace-existing",
target: "old-slot-result",
type: "default",
},
],
selectedNodeIds: ["slot-replace-existing"],
};
const plan: CanvasOperationPlan = {
version: 1,
operationId: "op-replace-existing-slot-fill",
operation: "fill_slot",
projectId,
targetSlotId: "slot-replace-existing",
mediaItems: [buildMutationMediaItem("media-new-slot-result")],
};
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData,
plan,
})
).toThrow(/需要用户确认/);
let staleError: unknown;
try {
applyCanvasOperationPlanToCanvasData({
canvasData,
baseCanvasWasStale: true,
plan: {
...plan,
confirmed: true,
},
});
} catch (error) {
staleError = error;
}
expect(staleError).toBeInstanceOf(CanvasAgentMutationError);
expect((staleError as CanvasAgentMutationError).code).toBe(
"STALE_CANVAS_REVISION"
);
const replaced = applyCanvasOperationPlanToCanvasData({
canvasData,
plan: {
...plan,
confirmed: true,
metadata: {
confirmedByUser: true,
},
},
});
const replacedNodes = getResultNodes(replaced.canvasData);
const newNodeId = replaced.createdNodeIds[0];
expect(replaced.createdNodeIds).toHaveLength(1);
expect(replaced.createdNodeIdsByMediaItemId).toEqual({
"media-new-slot-result": newNodeId,
});
expect(replacedNodes.some((node) => node.id === "old-slot-result")).toBe(false);
expect(replacedNodes.some((node) => node.id === "unrelated-image")).toBe(true);
expect(replacedNodes.find((node) => node.id === "slot-replace-existing"))
.toMatchObject({
data: {
filledNodeIds: [newNodeId],
metadata: {
replacedSlotNodeIds: ["old-slot-result"],
replacedByCanvasAgentOperationId: "op-replace-existing-slot-fill",
},
},
});
expect(replacedNodes.find((node) => node.id === newNodeId)).toMatchObject({
data: {
metadata: {
replacedSlotNodeIds: ["old-slot-result"],
replacedSlotId: "slot-replace-existing",
},
},
});
expect(getResultEdges(replaced.canvasData)).toEqual([
expect.objectContaining({
source: "slot-replace-existing",
target: newNodeId,
}),
]);
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: replaced.canvasData,
plan: {
...plan,
confirmed: true,
},
});
expect(retry.createdNodeIds).toEqual(replaced.createdNodeIds);
expect(getResultNodes(retry.canvasData)).toHaveLength(replacedNodes.length);
expect(
getResultNodes(retry.canvasData).filter((node) => node.id === newNodeId)
).toHaveLength(1);
});
test("creates append revision nodes without overwriting the source", () => {
const source = buildImageNode("image-source");
const result = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([source]),
plan: {
version: 1,
operationId: "op-append-revision",
operation: "append_revision",
projectId,
sourceNodeIds: ["image-source"],
anchorNodeId: "image-source",
placement: "right",
mediaItems: [
{
...buildMutationMediaItem("media-revision", ["image-source"]),
prompt: "turn the background white",
metadata: {
model: "test-image-model",
provider: "test-provider",
},
},
],
metadata: {
messageId: "assistant-message-1",
chatId: "chat-1",
model: "plan-level-model",
},
},
});
const createdNode = getResultNodes(result.canvasData).find(
(node) => node.id === result.createdNodeIds[0]
);
expect(result.createdNodeIdsByMediaItemId["media-revision"]).toBe(
result.createdNodeIds[0]
);
expect(createdNode).toMatchObject({
type: "image",
data: {
src: "https://cdn.example.com/media-revision.png",
prompt: "turn the background white",
metadata: {
generatedByAgent: true,
canvasAgentOperationId: "op-append-revision",
canvasAgentMediaItemId: "media-revision",
revisionOf: "image-source",
sourceNodeIds: ["image-source"],
messageId: "assistant-message-1",
chatId: "chat-1",
model: "test-image-model",
provider: "test-provider",
},
},
});
expect(
getResultNodes(result.canvasData).find((node) => node.id === "image-source")
).toMatchObject({
data: { src: "https://cdn.example.com/image-source.png" },
});
expect(getResultEdges(result.canvasData)).toEqual([
expect.objectContaining({
source: "image-source",
target: result.createdNodeIds[0],
type: "reference",
}),
]);
});
test("adds annotation metadata to media nodes without duplicating retries", () => {
const target = buildImageNode("image-annotated");
const plan: CanvasOperationPlan = {
version: 1,
operationId: "op-add-annotation",
operation: "add_annotation",
projectId,
targetNodeIds: ["image-annotated"],
textContent: "背景换浅灰,主体保持不变",
annotations: [
{
type: "box",
text: "处理这块背景",
bounds: { x: 0.12, y: 0.2, width: 0.5, height: 0.32 },
color: "#f59e0b",
},
],
metadata: {
messageId: "assistant-message-annotation",
chatId: "chat-1",
},
};
const first = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([target]),
plan,
});
const annotatedNode = getResultNodes(first.canvasData).find(
(node) => node.id === "image-annotated"
);
expect(first.createdNodeIds).toEqual([]);
expect(first.updatedNodeIds).toEqual(["image-annotated"]);
expect(annotatedNode).toMatchObject({
data: {
hasAnnotations: true,
metadata: {
annotations: [
expect.objectContaining({
id: "op-add-annotation:annotation-image-annotated-1",
sourceNodeId: "image-annotated",
type: "box",
text: "处理这块背景",
bounds: { x: 0.12, y: 0.2, width: 0.5, height: 0.32 },
createdByAgent: true,
canvasAgentOperationId: "op-add-annotation",
}),
],
},
},
});
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: first.canvasData,
plan,
});
const retryNode = getResultNodes(retry.canvasData).find(
(node) => node.id === "image-annotated"
);
expect(retry.updatedNodeIds).toEqual([]);
expect(retryNode.data.metadata.annotations).toHaveLength(1);
});
test("renames canvas nodes with trace metadata", () => {
const target = buildImageNode("image-rename");
target.data.title = "旧主图";
const result = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([target]),
plan: {
version: 1,
operationId: "op-rename-node",
operation: "rename_node",
projectId,
targetNodeIds: ["image-rename"],
textContent: "白底主图 A",
metadata: {
messageId: "assistant-message-rename",
chatId: "chat-1",
},
},
});
const renamedNode = getResultNodes(result.canvasData).find(
(node) => node.id === "image-rename"
);
expect(result.createdNodeIds).toEqual([]);
expect(result.updatedNodeIds).toEqual(["image-rename"]);
expect(renamedNode).toMatchObject({
data: {
title: "白底主图 A",
metadata: {
renamedByAgent: true,
canvasAgentOperationId: "op-rename-node",
canvasAgentOperation: "rename_node",
previousTitle: "旧主图",
},
},
});
});
test("updates image notes and syncs note annotations with trace metadata", () => {
const target = buildImageNode("image-note");
(target.data as any).note = "旧备注";
const result = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([target]),
plan: {
version: 1,
operationId: "op-update-note",
operation: "update_note",
projectId,
targetNodeIds: ["image-note"],
textContent: "保留主体轮廓,背景可以更干净",
metadata: {
messageId: "assistant-message-note",
chatId: "chat-1",
},
},
});
const notedNode = getResultNodes(result.canvasData).find(
(node) => node.id === "image-note"
);
expect(result.createdNodeIds).toEqual([]);
expect(result.updatedNodeIds).toEqual(["image-note"]);
expect(notedNode).toMatchObject({
data: {
note: "保留主体轮廓,背景可以更干净",
hasAnnotations: true,
metadata: {
noteUpdatedByAgent: true,
canvasAgentOperationId: "op-update-note",
canvasAgentOperation: "update_note",
previousNote: "旧备注",
messageId: "assistant-message-note",
annotations: [
expect.objectContaining({
id: "image-node-note:image-note",
sourceNodeId: "image-note",
type: "text-note",
text: "保留主体轮廓,背景可以更干净",
createdByAgent: true,
updatedByAgent: true,
canvasAgentOperationId: "op-update-note",
}),
],
},
},
});
});
test("keeps batch media writes idempotent by operation id", () => {
const anchor = buildImageNode("image-anchor");
const plan = {
version: 1 as const,
operationId: "op-batch-idempotent",
operation: "insert_beside" as const,
projectId,
anchorNodeId: "image-anchor",
placementStrategy: "grid" as const,
mediaItems: [1, 2, 3].map((index) =>
buildMutationMediaItem(`media-batch-${index}`)
),
};
const first = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([anchor]),
plan,
});
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: first.canvasData,
plan,
});
expect(first.createdNodeIds).toHaveLength(3);
expect(Object.keys(first.createdNodeIdsByMediaItemId).sort()).toEqual([
"media-batch-1",
"media-batch-2",
"media-batch-3",
]);
expect(retry.createdNodeIds).toEqual(first.createdNodeIds);
expect(retry.createdNodeIdsByMediaItemId).toEqual(
first.createdNodeIdsByMediaItemId
);
expect(getResultNodes(retry.canvasData)).toHaveLength(
getResultNodes(first.canvasData).length
);
});
test("requires explicit confirmation for replace media and rejects stale confirmed replacements", () => {
const target = buildImageNode("image-replace");
const replacePlan = {
version: 1 as const,
operationId: "op-replace-media",
operation: "replace_node_media" as const,
projectId,
targetNodeIds: ["image-replace"],
mediaItems: [buildMutationMediaItem("media-replacement")],
};
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([target]),
plan: replacePlan,
})
).toThrow(/需要用户确认/);
let caughtError: unknown;
try {
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([target]),
baseCanvasWasStale: true,
plan: {
...replacePlan,
confirmed: true,
},
});
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(CanvasAgentMutationError);
expect((caughtError as CanvasAgentMutationError).code).toBe(
"STALE_CANVAS_REVISION"
);
});
test("confirms move-node mutations and keeps retries from moving nodes again", () => {
const first = buildImageNode("image-move-a");
first.position = { x: 300, y: 100 };
const second = buildImageNode("image-move-b");
second.position = { x: 100, y: 100 };
const unrelated = buildImageNode("image-unrelated");
unrelated.position = { x: 900, y: 500 };
const canvasData = getCanvasData([first, second, unrelated]);
const plan: CanvasOperationPlan = {
version: 1,
operationId: "op-move-nodes",
operation: "move_nodes",
projectId,
targetNodeIds: ["image-move-a", "image-move-b"],
targetPosition: { x: 10, y: 20 },
layoutStrategy: "row",
spacing: 30,
metadata: {
messageId: "assistant-message-move",
chatId: "chat-1",
},
};
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData,
plan,
})
).toThrow(/需要用户确认/);
const moved = applyCanvasOperationPlanToCanvasData({
canvasData,
plan: {
...plan,
confirmed: true,
metadata: {
...plan.metadata,
confirmedByUser: true,
},
},
});
const movedNodes = getResultNodes(moved.canvasData);
const movedFirst = movedNodes.find((node) => node.id === "image-move-a");
const movedSecond = movedNodes.find((node) => node.id === "image-move-b");
const movedUnrelated = movedNodes.find((node) => node.id === "image-unrelated");
expect(moved.createdNodeIds).toEqual([]);
expect(moved.updatedNodeIds).toEqual(["image-move-b", "image-move-a"]);
expect(movedSecond).toMatchObject({
position: { x: 10, y: 20 },
data: {
metadata: {
movedByAgent: true,
canvasAgentOperationId: "op-move-nodes",
canvasAgentOperation: "move_nodes",
canvasAgentLayoutStrategy: "row",
previousPosition: { x: 100, y: 100 },
},
},
});
expect(movedFirst).toMatchObject({
position: { x: 360, y: 20 },
data: {
metadata: {
movedByAgent: true,
previousPosition: { x: 300, y: 100 },
},
},
});
expect(movedUnrelated?.position).toEqual({ x: 900, y: 500 });
expect((moved.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([
"image-move-b",
"image-move-a",
]);
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: moved.canvasData,
plan: {
...plan,
confirmed: true,
metadata: {
...plan.metadata,
confirmedByUser: true,
},
},
});
const retryNodes = getResultNodes(retry.canvasData);
expect(new Set(retry.updatedNodeIds)).toEqual(
new Set(["image-move-a", "image-move-b"])
);
expect(retryNodes.find((node) => node.id === "image-move-b")?.position).toEqual({
x: 10,
y: 20,
});
expect(retryNodes.find((node) => node.id === "image-move-a")?.position).toEqual({
x: 360,
y: 20,
});
});
test("rejects confirmed stale move-node mutations", () => {
const target = buildImageNode("image-stale-move");
let caughtError: unknown;
try {
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([target]),
baseCanvasWasStale: true,
plan: {
version: 1,
operationId: "op-stale-move-node",
operation: "move_nodes",
projectId,
targetNodeIds: ["image-stale-move"],
targetPosition: { x: 10, y: 20 },
confirmed: true,
metadata: {
confirmedByUser: true,
},
},
});
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(CanvasAgentMutationError);
expect((caughtError as CanvasAgentMutationError).code).toBe(
"STALE_CANVAS_REVISION"
);
});
test("confirms delete-node mutations, removes connected edges, and keeps retries idempotent", () => {
const source = buildImageNode("image-source");
const target = buildImageNode("image-delete");
const unrelated = buildImageNode("image-unrelated");
const edge = {
id: "edge-source-delete",
source: "image-source",
target: "image-delete",
type: "reference",
createdAt: "2026-06-22T00:00:00.000Z",
};
const canvasData = {
...getCanvasData([source, target, unrelated]),
edges: [edge],
selectedNodeIds: ["image-delete"],
};
const plan: CanvasOperationPlan = {
version: 1,
operationId: "op-delete-node",
operation: "delete_nodes",
projectId,
targetNodeIds: ["image-delete"],
metadata: {
messageId: "assistant-message-delete",
chatId: "chat-1",
},
};
expect(() =>
applyCanvasOperationPlanToCanvasData({
canvasData,
plan,
})
).toThrow(/需要用户确认/);
const deleted = applyCanvasOperationPlanToCanvasData({
canvasData,
plan: {
...plan,
confirmed: true,
metadata: {
...plan.metadata,
confirmedByUser: true,
},
},
});
const deletedCanvasData = deleted.canvasData as {
nodes?: any[];
edges?: any[];
selectedNodeIds?: string[];
metadata?: Record<string, any>;
};
expect(deleted.createdNodeIds).toEqual([]);
expect(deleted.updatedNodeIds).toEqual(["image-delete"]);
expect(deletedCanvasData.nodes?.map((node) => node.id).sort()).toEqual([
"image-source",
"image-unrelated",
]);
expect(deletedCanvasData.edges).toEqual([]);
expect(deletedCanvasData.selectedNodeIds).toEqual([]);
expect(
deletedCanvasData.nodes?.find((node) => node.id === "image-source")?.connections
).toEqual([]);
expect(deletedCanvasData.metadata?.canvasAgentOperationAudit).toEqual([
expect.objectContaining({
operationId: "op-delete-node",
operation: "delete_nodes",
targetNodeIds: ["image-delete"],
deletedNodeIds: ["image-delete"],
deletedEdges: [edge],
metadata: expect.objectContaining({
confirmedByUser: true,
}),
}),
]);
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: deleted.canvasData,
plan: {
...plan,
confirmed: true,
metadata: {
...plan.metadata,
confirmedByUser: true,
},
},
});
expect(retry.updatedNodeIds).toEqual(["image-delete"]);
expect(getResultNodes(retry.canvasData)).toHaveLength(2);
expect(getResultEdges(retry.canvasData)).toEqual([]);
});
test("rejects confirmed stale delete-node mutations", () => {
const target = buildImageNode("image-stale-delete");
let caughtError: unknown;
try {
applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([target]),
baseCanvasWasStale: true,
plan: {
version: 1,
operationId: "op-stale-delete-node",
operation: "delete_nodes",
projectId,
targetNodeIds: ["image-stale-delete"],
confirmed: true,
metadata: {
confirmedByUser: true,
},
},
});
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(CanvasAgentMutationError);
expect((caughtError as CanvasAgentMutationError).code).toBe(
"STALE_CANVAS_REVISION"
);
});
});