import { expect, test } from "@playwright/test"; import { tool } from "ai"; import { z } from "zod"; import type { AgentRunSnapshot } from "@/lib/ai/canvas-agent-runtime/protocol"; import type { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task"; import { serverLoader } from "../helpers/canvas-download-server-loader"; function fixture() { const load = serverLoader({ "./native-runtime": {} }); const run: typeof runZcodeCanvasTask = load("lib/ai/zcode-agent/task.ts").runZcodeCanvasTask; const snapshot: AgentRunSnapshot = load("lib/ai/canvas-agent-runtime/protocol.ts").newAgentRunSnapshot("替换图片中的底座", []); const ports: Parameters[0] = { runId: "original-request", snapshot, instructions: "", context: {}, gateway: { modelId: "test", apiType: "openai-chat-completions", forward: async () => Response.json({}) }, signal: new AbortController().signal, definitions: { planCanvasTask: tool({ inputSchema: z.object({}) }), generateCanvasImages: tool({ inputSchema: z.object({ referenceNodeIds: z.array(z.string()) }) }), }, tools: { planCanvasTask: { title: "安排步骤", readOnly: true, concurrentSafe: true, execute: async () => ({ steps: ["替换底座"] }) }, generateCanvasImages: { title: "生成图片", readOnly: false, concurrentSafe: false, execute: async () => ({ createdNodeIds: ["repaired-result"] }) }, }, checkpoint: async () => undefined, consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false, verify: async () => ({ passed: true, issues: [] }), }; return { ports, run, snapshot }; } const result = { type: "result", sessionId: "test-session", response: "已完成" }; test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => { const { ports, run, snapshot } = fixture(); const emitted: string[] = [], saved: string[] = []; let submissions = 0; ports.tools.generateCanvasImages.execute = async () => { submissions++; throw Object.assign(new Error("This operation was aborted"), { name: "AbortError" }); }; ports.onAssistantText = text => emitted.push(text); ports.checkpoint = async state => { saved.push(state.message); }; ports.native = async options => { await options.execute("planCanvasTask", {}, "plan", options.signal!); await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "unknown-result", options.signal!)).rejects.toThrow(); expect(options.signal!.aborted).toBe(true); expect(saved.at(-1)).toContain("请求编号:original-request"); await options.onEvent?.({ type: "model.streaming", sessionId: "test-session", payload: { kind: "text_delta", assistantMessageId: "after-error", delta: "新建对话绕过原任务后再试" } }); await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "duplicate-paid", options.signal!)).rejects.toThrow(); throw new Error("planner stopped"); }; expect(await run(ports)).toBe("needs_review"); expect(submissions).toBe(1); expect(emitted).toEqual([]); expect(snapshot.message).toContain("结果尚未确认"); expect(snapshot.message).toContain("连接已中止"); expect(snapshot.message).toContain("不要换模型、新建对话或重新提交"); expect(snapshot.message).toContain(ports.runId); ports.native = async () => { throw new Error("must not start a new attempt"); }; expect(await run(ports)).toBe("needs_review"); expect(submissions).toBe(1); }); test("repairing a failed result permits original images as references without admitting another passed target", async () => { const { ports, run, snapshot } = fixture(); let submissions = 0; snapshot.verification = { passed: false, issues: ["底座不符"], retryable: true, failedNodeIds: ["failed-result"] }; ports.tools.generateCanvasImages.execute = async input => { submissions++; expect(input.referenceNodeIds).toEqual(["failed-result", "original-scene", "original-product"]); return { createdNodeIds: ["repaired-result"] }; }; ports.native = async options => { await options.execute("planCanvasTask", {}, "plan", options.signal!); await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original-scene", "passed-result"] }, "wrong-target", options.signal!)).rejects.toThrow("未通过"); await options.execute("generateCanvasImages", { referenceNodeIds: ["failed-result", "original-scene", "original-product"] }, "repair-failed", options.signal!); return result; }; expect(await run(ports)).toBe("completed"); expect(submissions).toBe(1); }); test("an unresolved image read reports the concrete input failure and recommendation", async () => { const { ports, run, snapshot } = fixture(); ports.definitions.readCanvas = tool({ inputSchema: z.object({}) }); ports.tools.readCanvas = { title: "读取图片", readOnly: true, concurrentSafe: true, execute: async () => { throw new Error("无法获取图片,请检查图片链接是否有效:fetch failed"); } }; ports.native = async options => { await expect(options.execute("readCanvas", {}, "failed-read", options.signal!)).rejects.toThrow(); expect(options.signal!.aborted).toBe(false); return result; }; expect(await run(ports)).toBe("needs_review"); expect(snapshot.message).toContain("图片无法读取"); expect(snapshot.message).toContain("重新上传原图"); expect(snapshot.message).toContain(ports.runId); });