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

511 lines
18 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 { generateText, stepCountIs, tool, type ModelMessage } from "ai";
import type { LanguageModelV2 } from "@ai-sdk/provider";
import { z } from "zod";
import { ToolScheduler } from "@/lib/ai/third-party/zcode/scheduler";
import {
compactAgentContext,
splitCompactableHistory,
} from "@/lib/ai/canvas-agent-runtime/context";
import { runCanvasAgentLoop, type CanvasAgentLoopPorts } from "@/lib/ai/canvas-agent-runtime/loop";
import {
AgentRunControlChangedError,
newAgentRunSnapshot,
publicAgentError,
type AgentCall,
type AgentRunSnapshot,
type CanvasAgentRunStatus,
} from "@/lib/ai/canvas-agent-runtime/protocol";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import { inferCanvasTaskRequirements } from "@/lib/ai/canvas-agent-runtime/requirements";
import { finalResultNodeIds, originalSourceNodeIds } from "@/lib/ai/canvas-agent-runtime/results";
function step(calls: AgentCall[] = [], text = "已处理") {
return {
text,
calls,
messages: [
{
role: "assistant",
content: calls.length
? calls.map((call) => ({
type: "tool-call",
toolCallId: call.id,
toolName: call.name,
input: call.input,
}))
: text,
},
] as ModelMessage[],
inputTokens: 100,
outputTokens: 30,
};
}
const call = (id: string, name = "readCanvas"): AgentCall => ({
id,
name,
input: { nodeIds: [id] },
});
function harness(
sequence: ReturnType<typeof step>[],
overrides: Partial<CanvasAgentLoopPorts> = {},
) {
const snapshots: Array<{ state: AgentRunSnapshot; status: CanvasAgentRunStatus }> = [];
const executed: string[] = [];
const ports: CanvasAgentLoopPorts = {
model: async () => {
const result = sequence.shift();
if (!result) throw new Error("Unexpected model call");
return result;
},
tools: {
planCanvasTask: {
title: "计划",
readOnly: true,
concurrentSafe: true,
execute: async () => ({ expectedImageCount: 1 }),
},
readCanvas: {
title: "读取",
readOnly: true,
concurrentSafe: true,
execute: async (_input, id) => {
executed.push(id);
return { nodes: [] };
},
},
generateCanvasImages: {
title: "生成",
readOnly: false,
concurrentSafe: false,
execute: async (_input, id) => {
executed.push(id);
return { createdNodeIds: [`image-${id}`], status: "succeeded" };
},
},
},
checkpoint: async (state, status) => {
snapshots.push({ state: structuredClone(state), status });
},
consumeInputs: async () => ({ cancelled: false }),
isCancelled: async () => false,
summarize: async () => "保留主体颜色,不改变商品结构",
verify: async () => ({ passed: true, issues: [] }),
...overrides,
};
const state = newAgentRunSnapshot("先抠图,再换背景,保持商品结构", [
{ role: "user", content: "先抠图,再换背景" },
]);
return { state, ports, snapshots, executed };
}
const plan = () => step([call("plan", "planCanvasTask")]);
test("all image and video canvas Agent requests use native runtime; Claw and direct modes remain separate", () => {
const body = { composerMode: "agent", contextPayload: { source: "canvas", projectId: "owned-project" } };
for (const override of [{}, { agentPresetId: "preset" }, { workspaceVariant: "video" },
{ message: { parts: [{ type: "text", text: "只分析不执行" }] } }, { resumeRunId: "run" }])
expect(shouldUseCanvasAgentRuntime({ ...body, ...override })).toBe(true);
for (const override of [{ composerMode: "clawd" }, { composerMode: "image" }, { composerMode: "video" },
{ contextPayload: { source: "canvas" } }, { contextPayload: { source: "chat", projectId: "p" } }])
expect(shouldUseCanvasAgentRuntime({ ...body, ...override })).toBe(false);
});
test("continues beyond eight model rounds and verifies actual tool results before completion", async () => {
const h = harness([
plan(),
...Array.from({ length: 10 }, (_, index) => step([call(String(index))])),
step(),
]);
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("completed");
expect(h.state.steps).toBe(12);
expect(h.executed).toHaveLength(10);
expect(h.snapshots.at(-1)?.state.verification?.passed).toBe(true);
});
test("checkpoint is durable before a tool with side effects starts", async () => {
const h = harness([plan(), step([call("g", "generateCanvasImages")]), step()]);
h.ports.tools.generateCanvasImages.execute = async (_input, id) => {
expect(h.snapshots.at(-1)?.state.calls.find((item) => item.id === id)?.status).toBe("started");
return { createdNodeIds: ["result"], status: "succeeded" };
};
await runCanvasAgentLoop(h.state, h.ports);
expect(h.state.resultNodeIds).toEqual(["result"]);
});
test("does not run paid tools when the model skipped the plan", async () => {
const h = harness([step([call("g", "generateCanvasImages")])]);
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("needs_review");
expect(h.executed).toEqual([]);
});
test("steering waits until every tool result in the batch is settled", async () => {
const h = harness([plan(), step([call("a"), call("b")]), step()]);
let pending = false;
h.ports.tools.readCanvas.execute = async (_input, id) => {
pending = true;
await new Promise((resolve) => setTimeout(resolve, 2));
return { nodeId: id };
};
h.ports.consumeInputs = async (state) => {
if (pending) {
const toolIds = state.messages.flatMap((message) =>
message.role === "tool" ? message.content.map((part) => part.toolCallId) : [],
);
expect(toolIds).toContain("a");
expect(toolIds).toContain("b");
state.messages.push({ role: "user", content: "不要改变产品颜色" });
state.inputs.push({ id: "extra", text: "不要改变产品颜色", applied: true });
pending = false;
}
return { cancelled: false };
};
await runCanvasAgentLoop(h.state, h.ports);
expect(h.state.inputs).toHaveLength(1);
});
test("stopping an active image request preserves its output and skips later tools", async () => {
const h = harness([
plan(),
step([call("a", "generateCanvasImages"), call("b", "generateCanvasImages")]),
]);
let cancelled = false;
h.ports.isCancelled = async () => cancelled;
h.ports.tools.generateCanvasImages.execute = async (_input, id) => {
h.executed.push(id);
cancelled = true;
return { createdNodeIds: ["finished-image"] };
};
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("cancelled");
expect(h.executed).toEqual(["a"]);
expect(h.state.resultNodeIds).toEqual(["finished-image"]);
const last = h.state.messages.at(-1);
expect(last?.role === "tool" && last.content.length).toBe(2);
});
test("a failed paid request stops subsequent work and cannot be blindly replayed", async () => {
const h = harness([
plan(),
step([call("a", "generateCanvasImages"), call("b", "generateCanvasImages")]),
]);
h.ports.tools.generateCanvasImages.execute = async () => {
throw new Error("<html>Cloudflare 524 ray id secret</html>");
};
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("needs_review");
expect(h.state.calls.find((item) => item.id === "a")?.uncertain).toBe(true);
expect(h.state.calls.find((item) => item.id === "a")?.error).not.toContain("<html>");
h.ports.model = async () => {
throw new Error("must not call model");
};
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("needs_review");
});
test("review failure never reports completion even when the assistant says it finished", async () => {
const h = harness([plan(), step([call("g", "generateCanvasImages")]), step([], "全部完成")], {
verify: async () => {
throw new Error("review provider unavailable");
},
});
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("needs_review");
expect(h.snapshots.every((item) => item.status !== "completed")).toBe(true);
});
test("an interactive panel is a waiting state, not a completed image", async () => {
const h = harness([plan(), step([call("interactive")])]);
h.ports.tools.readCanvas.execute = async () => ({ mode: "interactive", status: "succeeded" });
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("needs_review");
});
test("resumes persisted tool outputs without invoking the tool again", async () => {
const h = harness([step()]);
h.state.calls = [
{ ...call("plan", "planCanvasTask"), status: "succeeded", output: {} },
{
...call("g", "generateCanvasImages"),
status: "succeeded",
output: { createdNodeIds: ["existing"] },
},
];
h.state.messages.push(
...step([call("plan", "planCanvasTask"), call("g", "generateCanvasImages")]).messages,
);
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("completed");
expect(h.executed).toEqual([]);
expect(h.state.messages.filter((message) => message.role === "tool")).toHaveLength(2);
});
test("input accepted at the completion boundary is processed instead of silently dropped", async () => {
const h = harness([plan(), step([call("a")]), step(), step([call("b")]), step()]);
let extra = false;
const checkpoint = h.ports.checkpoint;
h.ports.checkpoint = async (state, status) => {
if (status === "completed" && !extra) {
extra = true;
throw new AgentRunControlChangedError();
}
await checkpoint(state, status);
};
h.ports.consumeInputs = async (state) => {
if (extra && !state.inputs.length) {
state.messages.push({ role: "user", content: "补一版竖图" });
state.inputs.push({ id: "tail", text: "补一版竖图", applied: true });
}
return { cancelled: false };
};
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("completed");
expect(h.executed).toEqual(["a", "b"]);
});
test("resume gets a fresh token budget while preserving the total usage ledger", async () => {
const h = harness([step([call("r")]), step()]);
h.state.inputTokens = 100_000;
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("completed");
expect(h.state.inputTokens).toBeGreaterThan(100_000);
});
test("compact preserves complete tool pairs and does not change history when summarization fails", async () => {
const messages: ModelMessage[] = [];
for (let index = 0; index < 5; index++)
messages.push(
{ role: "user", content: `要求-${index}` },
...step([call(String(index))]).messages,
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: String(index),
toolName: "readCanvas",
output: { type: "text", value: "x".repeat(3000) },
},
],
},
);
const split = splitCompactableHistory(messages, 2);
expect(split.older.at(-1)?.role).toBe("tool");
expect(split.recent[0].role).toBe("user");
const before = JSON.stringify(messages);
await expect(
compactAgentContext({
messages,
previousSummary: "",
threshold: 1,
summarize: async () => {
throw new Error("unavailable");
},
}),
).rejects.toThrow("unavailable");
expect(JSON.stringify(messages)).toBe(before);
const compacted = await compactAgentContext({
messages,
previousSummary: "",
threshold: 1,
summarize: async () => "用户要求不改颜色;已生成 image-1",
});
expect(compacted.previousSummary).toContain("不改颜色");
expect(compacted.messages[0].role).toBe("user");
});
test("vendored scheduler respects dependencies and separates unsafe mutations", () => {
const scheduler = new ToolScheduler({ maxConcurrency: 10 });
expect(
scheduler.schedule([
{ toolCallId: "r1", dependsOn: [], readOnly: true },
{ toolCallId: "r2", dependsOn: [], readOnly: true },
{ toolCallId: "w", dependsOn: ["r1", "r2"], concurrentSafe: false },
{ toolCallId: "last", dependsOn: ["w"], readOnly: true },
]).parallelGroups,
).toEqual([["r1", "r2"], ["w"], ["last"]]);
expect(() =>
scheduler.schedule([
{ toolCallId: "a", dependsOn: ["b"] },
{ toolCallId: "b", dependsOn: ["a"] },
]),
).toThrow();
});
test("public errors strip provider HTML and common key formats", () => {
expect(publicAgentError("<!doctype html><body>gateway</body>")).not.toContain("html");
expect(publicAgentError("Authorization: Bearer abcdefghijklmnop")).not.toContain(
"abcdefghijklmnop",
);
});
test("real AI SDK tool calls can be executed and fed back through the adapted loop", async () => {
const fixtures: Array<Awaited<ReturnType<LanguageModelV2["doGenerate"]>>> = [
{
content: [{ type: "tool-call", toolCallId: "plan", toolName: "planCanvasTask", input: "{}" }],
finishReason: "tool-calls",
usage: { inputTokens: 20, outputTokens: 10, totalTokens: 30 },
warnings: [],
},
{
content: [
{ type: "tool-call", toolCallId: "image", toolName: "generateCanvasImages", input: "{}" },
],
finishReason: "tool-calls",
usage: { inputTokens: 30, outputTokens: 10, totalTokens: 40 },
warnings: [],
},
{
content: [{ type: "text", text: "已完成" }],
finishReason: "stop",
usage: { inputTokens: 40, outputTokens: 10, totalTokens: 50 },
warnings: [],
},
];
const requests: Array<Parameters<LanguageModelV2["doGenerate"]>[0]> = [];
const model: LanguageModelV2 = {
specificationVersion: "v2",
provider: "test",
modelId: "canvas-test",
supportedUrls: {},
doGenerate: async (request) => {
requests.push(request);
const result = fixtures.shift();
if (!result) throw new Error("No mock response");
return result;
},
doStream: async () => {
throw new Error("Unexpected stream request");
},
};
const h = harness([]);
const definitions = {
planCanvasTask: tool({ description: "plan", inputSchema: z.object({}) }),
generateCanvasImages: tool({ description: "generate", inputSchema: z.object({}) }),
};
h.ports.model = async (messages) => {
const result = await generateText({
model,
messages,
tools: definitions,
stopWhen: stepCountIs(1),
});
return {
text: result.text,
calls: result.toolCalls.map((item) => ({
id: item.toolCallId,
name: item.toolName,
input: z.record(z.unknown()).parse(item.input),
})),
messages: result.response.messages,
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
};
};
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("completed");
expect(h.executed).toEqual(["image"]);
expect(requests[2].prompt.some((message) => message.role === "tool")).toBe(true);
});
test("resuming compacted history does not resurrect orphaned tool results", async () => {
const h = harness([step()]);
h.state.summary = "旧工具完成并已压缩,结果 existing-node";
h.state.calls = [
{ ...call("old"), status: "succeeded", output: { createdNodeIds: ["existing-node"] } },
];
h.ports.model = async (messages) => {
expect(messages.some((message) => message.role === "tool")).toBe(false);
return step();
};
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("completed");
});
test("long single-turn tasks compact at complete tool batches", async () => {
const messages: ModelMessage[] = [{ role: "user", content: "保持主体不变" }];
for (let i = 0; i < 12; i++)
messages.push(...step([call(String(i))]).messages, {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: String(i),
toolName: "readCanvas",
output: { type: "text", value: "result" },
},
],
});
const parts = splitCompactableHistory(messages);
expect(parts.older.length).toBeGreaterThan(0);
expect(parts.older.at(-1)?.role).toBe("tool");
expect(parts.recent[0].role).toBe("assistant");
});
test("image requirements calculate per-image variants and avoid inventing a ratio for mixed formats", () => {
expect(inferCanvasTaskRequirements("这三张商品图统一背景,做成3:4,每张出两版", 3)).toEqual({
expectedImageCount: 6,
aspectRatio: "3:4",
});
expect(inferCanvasTaskRequirements("最终生成6张,比例1:1")).toEqual({
expectedImageCount: 6,
aspectRatio: "1:1",
});
expect(inferCanvasTaskRequirements("生成3:4和16:9各一版").aspectRatio).toBeUndefined();
});
test("renaming final images does not make them intermediate outputs", () => {
const state = newAgentRunSnapshot("换背景再增强", []);
state.resultNodeIds = ["background", "enhanced"];
state.calls = [
{
...call("a", "generateCanvasImages"),
input: { referenceNodeIds: ["original"] },
status: "succeeded",
output: { createdNodeIds: ["background"] },
},
{
...call("b", "enhanceImage"),
input: { nodeIds: ["background"] },
status: "succeeded",
output: { createdNodeIds: ["enhanced"] },
},
{
...call("c", "rename"),
input: { nodeIds: ["enhanced"] },
status: "succeeded",
output: { createdNodeIds: [] },
},
];
expect(finalResultNodeIds(state)).toEqual(["enhanced"]);
expect(originalSourceNodeIds(state, ["enhanced"])).toEqual(["original"]);
});
test("only known failed results are eligible for bounded automatic rework", async () => {
const h = harness([
plan(),
step([call("first", "generateCanvasImages")]),
step(),
step([
{ id: "repair", name: "generateCanvasImages", input: { referenceNodeIds: ["image-first"] } },
]),
step(),
]);
let reviewed = false;
h.ports.verify = async () => {
if (reviewed) return { passed: true, issues: [] };
reviewed = true;
return {
passed: false,
issues: ["背景未替换"],
failedNodeIds: ["image-first"],
retryable: true,
};
};
expect(await runCanvasAgentLoop(h.state, h.ports)).toBe("completed");
expect(h.state.repairs).toBe(1);
const outside = harness([
plan(),
step([call("first", "generateCanvasImages")]),
step(),
step([call("other", "generateCanvasImages")]),
]);
outside.ports.verify = async () => ({
passed: false,
issues: ["背景未替换"],
failedNodeIds: ["image-first"],
retryable: true,
});
expect(await runCanvasAgentLoop(outside.state, outside.ports)).toBe("needs_review");
expect(outside.executed).toEqual(["first"]);
});