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[], overrides: Partial = {}, ) { 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" }, { message: { parts: [{ type: "text", text: "只分析不执行" }] } }, { resumeRunId: "run" }]) expect(shouldUseCanvasAgentRuntime({ ...body, ...override })).toBe(true); for (const override of [{ workspaceVariant: "video" }, { 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("Cloudflare 524 ray id secret"); }; 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(""); 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("gateway")).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>> = [ { 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[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"]); });