ai-chatbot/tests/routes/canvas-agent-runtime-review.test.ts

206 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import type { LanguageModelV2 } from "@ai-sdk/provider";
import sharp from "sharp";
import { newAgentRunSnapshot } from "@/lib/ai/canvas-agent-runtime/protocol";
import {
analyzeRuntimeImages,
inspectImageAlpha,
verifyCanvasAgentResults,
} from "@/lib/ai/canvas-agent-runtime/review";
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
import { createAgentVisionExecutor } from "@/lib/ai/runtime/agent-vision-failover";
import type { AgentModelEntry } from "@/lib/ai/runtime/agent-model-failover";
async function fixture(answer: string, resultSize = { width: 30, height: 40 }) {
const requests: Array<Parameters<LanguageModelV2["doGenerate"]>[0]> = [];
const model: LanguageModelV2 = {
specificationVersion: "v2",
provider: "test",
modelId: "vision",
supportedUrls: {},
doGenerate: async (request) => {
requests.push(request);
return {
content: [{ type: "text", text: answer }],
finishReason: "stop",
usage: { inputTokens: 30, outputTokens: 10, totalTokens: 40 },
warnings: [],
};
},
doStream: async () => {
throw new Error("Unexpected stream");
},
};
const buffers = {
source: await sharp({ create: { width: 40, height: 40, channels: 3, background: "white" } })
.png()
.toBuffer(),
result: await sharp({ create: { ...resultSize, channels: 3, background: "white" } })
.png()
.toBuffer(),
};
const snapshot = newAgentRunSnapshot("背景改白色,主体结构不变,生成3:4", []);
snapshot.requirements = { expectedImageCount: 1, aspectRatio: "3:4" };
snapshot.resultNodeIds = ["result"];
snapshot.calls = [
{
id: "plan",
name: "planCanvasTask",
input: {},
status: "succeeded",
output: { expectedImageCount: 1, visualCriteria: ["主体结构不变"] },
},
{
id: "gen",
name: "generateCanvasImages",
input: { referenceNodeIds: ["source"] },
status: "succeeded",
output: { status: "succeeded", createdNodeIds: ["result"] },
},
];
const params = {
snapshot,
model,
userId: "test",
projectId: "test",
supportsVision: true,
reviewPorts: {
loadNodes: async (ids: string[]) =>
ids.map((id) => ({
id,
type: "image",
position: { x: 0, y: 0 },
data: { src: `https://assets.invalid/${id}` },
})),
readImage: async (url: string) => (url.endsWith("source") ? buffers.source : buffers.result),
},
};
return { params, requests };
}
test("checks real image bytes and compares results to their source images", async () => {
const f = await fixture('{"passed":true,"issues":[],"failedNodeIds":[]}');
const result = await verifyCanvasAgentResults(f.params);
expect(result.passed).toBe(true);
expect(f.requests).toHaveLength(1);
const content = f.requests[0].prompt.flatMap((message) =>
Array.isArray(message.content) ? message.content : [],
);
expect(
content.filter((part) => part.type === "file" && part.mediaType.startsWith("image/")),
).toHaveLength(2);
// A square source image is not incorrectly tested against the output's 3:4 ratio.
expect(result.issues).toEqual([]);
});
test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => {
const bad = await fixture("not JSON");
const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}');
const failures: string[] = [];
const entries = ["bad", "valid-negative"].map(id => ({ model: { id } }) as AgentModelEntry);
const withModel = createAgentVisionExecutor(entries, {
isPaused: async () => false,
fail: async entry => { failures.push(entry.model.id); },
}, async id => id === "bad" ? bad.params.model : validNegative.params.model);
const result = await verifyCanvasAgentResults({ ...validNegative.params, withModel });
expect(result.passed).toBe(false);
expect(result.issues).toEqual(["主体不一致"]);
expect(failures).toEqual(["bad"]);
expect(bad.requests).toHaveLength(1);
expect(validNegative.requests).toHaveLength(1);
});
test("actual dimensions override a model's claim that the output passed", async () => {
const f = await fixture('{"passed":true,"issues":[]}', { width: 60, height: 40 });
const result = await verifyCanvasAgentResults(f.params);
expect(result.passed).toBe(false);
expect(result.failedNodeIds).toEqual(["result"]);
expect(result.issues.join(" ")).toContain("3:4");
});
test("reported problems cannot be ignored by a contradictory passed=true flag", async () => {
const f = await fixture('{"passed":true,"issues":["主体细节已改变"],"failedNodeIds":["result"]}');
const result = await verifyCanvasAgentResults(f.params);
expect(result.passed).toBe(false);
expect(result.retryable).toBe(true);
});
test("review infrastructure failure never becomes an automatic pass", async () => {
const f = await fixture("<html>unavailable</html>");
await expect(verifyCanvasAgentResults(f.params)).rejects.toThrow();
});
test("non-images are rejected before any vision model invocation", async () => {
const f = await fixture('{"passed":true,"issues":[]}');
f.params.reviewPorts.readImage = async () => Buffer.from("not an image");
await expect(verifyCanvasAgentResults(f.params)).rejects.toThrow();
expect(f.requests).toHaveLength(0);
});
test("missing output counts cannot be disguised by the model's plan", async () => {
const f = await fixture('{"passed":true,"issues":[]}');
f.params.snapshot.requirements!.expectedImageCount = 6;
const result = await verifyCanvasAgentResults(f.params);
expect(result.passed).toBe(false);
expect(result.issues.join(" ")).toContain("6 张");
expect(f.requests).toHaveLength(0);
});
test("a text-only model cannot silently approve images", async () => {
const f = await fixture('{"passed":true,"issues":[]}');
await expect(
analyzeRuntimeImages({
...f.params,
supportsVision: false,
nodeIds: ["result"],
question: "检查",
}),
).rejects.toThrow("不支持图片检查");
});
async function cutoutFixture() {
const f = await fixture('{"passed":false,"issues":["背景是黑色"],"failedNodeIds":["result"]}');
f.params.snapshot.objective = "帮我将它扣出来保持透明背景";
f.params.snapshot.requirements = { expectedImageCount: 1 };
f.params.snapshot.calls[1].name = "removeBackground";
f.params.snapshot.calls[1].input = { nodeIds: ["source"] };
const subject = await sharp({ create: { width: 16, height: 16, channels: 4, background: "blue" } }).png().toBuffer();
const bytes = await sharp({ create: { width: 32, height: 32, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
.composite([{ input: subject, left: 8, top: 8 }]).png().toBuffer();
f.params.reviewPorts.readImage = async () => bytes;
return { ...f, bytes };
}
test("a real transparent cutout finishes from original file evidence without a vision request", async () => {
const f = await cutoutFixture();
expect(isCompletedSimpleCutout(f.params.snapshot)).toBe(true);
const result = await verifyCanvasAgentResults(f.params);
expect(result).toMatchObject({ passed: true, method: "alpha-channel", retryable: false });
expect(f.requests).toHaveLength(0);
});
test("opaque images and fully empty transparent images cannot complete a cutout", async () => {
const f = await cutoutFixture();
for (const background of ["white", { r: 0, g: 0, b: 0, alpha: 0 }]) {
const bytes = await sharp({ create: { width: 32, height: 32, channels: 4, background } }).png().toBuffer();
f.params.reviewPorts.readImage = async () => bytes;
expect(await verifyCanvasAgentResults(f.params)).toMatchObject({ passed: false, failedNodeIds: ["result"], retryable: false });
}
expect(f.requests).toHaveLength(0);
});
test("cutout completion does not skip later user actions, edge inspection or an incomplete batch", async () => {
const f = await cutoutFixture();
for (const request of ["抠图然后下载", "抠图后换背景生成三张", "抠图并检查边缘白边", "抠图后把毛改成绿色", "抠图后移动到左侧", "先重命名为小鸡再抠图", "抠图再做点别的", "remove background and recolor blue"]) {
f.params.snapshot.objective = request;
expect(isCompletedSimpleCutout(f.params.snapshot)).toBe(false);
}
f.params.snapshot.objective = "抠图";
f.params.snapshot.requirements!.expectedImageCount = 2;
expect(isCompletedSimpleCutout(f.params.snapshot)).toBe(false);
});
test("vision inspection receives alpha facts and an opaque checkerboard preview", async () => {
const f = await cutoutFixture();
await analyzeRuntimeImages({ ...f.params, nodeIds: ["result"], question: "检查主体边缘" });
const content = f.requests[0].prompt.flatMap(message => Array.isArray(message.content) ? message.content : []);
expect(JSON.stringify(content.filter(part => part.type === "text"))).toContain('hasTransparentPixels');
const image = content.find(part => part.type === "file") as { data: Uint8Array };
const facts = await inspectImageAlpha(Buffer.from(image.data));
expect(facts.hasTransparentPixels).toBe(false);
expect(facts.hasVisiblePixels).toBe(true);
});