From bab213abe140a0a820fd5c08b8d9c03e8d288a8b Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 21 Oct 2026 10:40:37 +0800 Subject: [PATCH] fix(agent): recover unavailable automatic image choices and correct regression fixtures --- lib/ai/canvas-agent-runtime/tools.ts | 2 +- ...as-agent-runtime-image-preferences.test.ts | 571 +++--------------- 2 files changed, 99 insertions(+), 474 deletions(-) diff --git a/lib/ai/canvas-agent-runtime/tools.ts b/lib/ai/canvas-agent-runtime/tools.ts index ff690172b..43553d6dd 100644 --- a/lib/ai/canvas-agent-runtime/tools.ts +++ b/lib/ai/canvas-agent-runtime/tools.ts @@ -205,7 +205,7 @@ export async function buildCanvasRuntimeTools(params: { const pinnedModel = resolveUserImageModel(params.objective || "", models, params.imagePreferences?.modelId); const requestedModel = pinnedModel || input.modelId || params.preferredModelId; const requestedEntry = requestedModel ? models.find(({ model }) => model.healthStatus !== "auto_disabled" && (model.id === requestedModel || model.modelId === requestedModel)) : null; - if (requestedModel && !requestedEntry) throw new Error("指定的图片模型不可用,未切换到其他模型收费生成"); + if (pinnedModel && !requestedEntry) throw new Error("指定的图片模型不可用,未切换到其他模型收费生成"); const chosen = requestedEntry || models.find( ({ model }) => model.id === route?.id && model.healthStatus !== "auto_disabled", ) || diff --git a/tests/routes/canvas-agent-runtime-image-preferences.test.ts b/tests/routes/canvas-agent-runtime-image-preferences.test.ts index 4c07b2fa5..0ac96f636 100644 --- a/tests/routes/canvas-agent-runtime-image-preferences.test.ts +++ b/tests/routes/canvas-agent-runtime-image-preferences.test.ts @@ -1,486 +1,103 @@ -import { - executeBoxCutoutJob, -} from "@/lib/ai/box-cutout/service"; -import { executeExtendJob } from "@/lib/ai/extend/service"; -import { executeLayerSplitJob } from "@/lib/ai/layer-split/service"; -import { executeResizeJob } from "@/lib/ai/resize/service"; -import { - executeRmbgJob, - fetchImageBufferFromUrl, -} from "@/lib/ai/rmbg/service"; -import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; -import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context"; -import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint"; -import { generateCanvasImage } from "@/lib/canvas/generate-image"; -import { requestLongTaskResult } from "@/lib/client/long-task"; -import { - findStyleProImageModelByRef, - getStyleProImageModelEntries, - sortStyleProImageModelEntries, -} from "@/lib/style-pro/model-options"; +import { expect, test } from "@playwright/test"; +import { serverLoader } from "../helpers/canvas-download-server-loader"; +import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences"; -export type CanvasCapabilityMediaResult = { - assetId?: string; - height?: number; - metadata?: Record; - nodeType?: "image" | "model3d" | "world3d"; - prompt?: string; - sourceNodeId: string; - title: string; - url: string; - width?: number; +const preferences = { + modelId: "user-image-model", + parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字", + quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 }, }; - -type HeadlessExecutorParams = { - command: CanvasCapabilityClientCommand; - requestHeaders: Headers; - requestUrl: string; - targets: CanvasCapabilityTarget[]; - userId: string; -}; - -function asFiniteNumber(value: unknown) { - const number = Number(value); - return Number.isFinite(number) ? number : undefined; -} - -function getCookieHeaders(headers: Headers) { - const cookie = headers.get("cookie") || ""; - return cookie ? { cookie } : undefined; -} - -async function postRuntimeEndpoint(params: { - body: Record; - path: string; - requestHeaders: Headers; - requestUrl: string; - timeoutMs?: number; -}) { - const response = await fetch(resolveCanvasRuntimeEndpoint(params.path, params.requestUrl), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...getCookieHeaders(params.requestHeaders), +function harness(results?: any[]) { + const saved: any[] = []; + const generated: any[] = []; + const operations: any[] = []; + const mock = serverLoader({ + "@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) }, + "@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] }, + "@/lib/canvas/agent-mutations": { applyCanvasOperationPlan: async (value: any) => { operations.push(value); return { createdNodeIds: ["new-image"] }; } }, + "@/lib/canvas/generate-image": { generateCanvasImage: async (value: any) => { generated.push(value); return { modelRef: results ? "backup" : value.model, images: results || [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }] }; } }, + "@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } }, + "@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) }, + "@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) }, + "@/lib/style-pro/model-options": { + getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })), + sortStyleProImageModelEntries: (entries: any) => entries, }, - body: JSON.stringify(params.body), - signal: AbortSignal.timeout(params.timeoutMs || 20 * 60 * 1000), + "./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: "https://test.example/reference.png" } }] }, + "./review": {}, + "./image-access-tools": { registerImageCanvasAccessTools() {} }, }); - const payload = await response.json().catch(() => null); - if (!response.ok || payload?.success === false || payload?.error) { - throw new Error( - String(payload?.error || payload?.detail || `能力服务返回 ${response.status}`) - ); - } - return payload as Record; -} - -async function postRuntimeFormData(params: { - formData: FormData; - path: string; - requestHeaders: Headers; - requestUrl: string; - timeoutMs?: number; -}) { - const response = await fetch(resolveCanvasRuntimeEndpoint(params.path, params.requestUrl), { - method: "POST", - headers: getCookieHeaders(params.requestHeaders), - body: params.formData, - signal: AbortSignal.timeout(params.timeoutMs || 20 * 60 * 1000), + const build = async (imagePreferences: any = preferences) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({ + userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"], + request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true, + preferredModelId: "system-default", imagePreferences, emit() {}, }); - const payload = await response.json().catch(() => null); - if (!response.ok || payload?.success === false || payload?.error) { - throw new Error( - String(payload?.error || payload?.detail || `能力服务返回 ${response.status}`) - ); - } - return payload as Record; + return { build, generated, operations, saved }; } +test("explicit controls override conflicting planner settings without losing false or zero values", () => { + const resolved = applyAgentImagePreferences({ modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1", prompt: "产品图" }, preferences); + expect(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" }); + expect(agentProviderImageParameters(resolved)).toEqual({ quality: "high", size: "1536*2048", watermark: false, seed: 0 }); +}); +test("real generation tool honors the chosen image model and all parameters despite another default and planner choice", async () => { + const h = harness(); const tools = await h.build(); + const output = await tools.executable.generateCanvasImages.execute({ prompt: "生成产品图", modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1" }, "call-1"); + expect(h.generated).toHaveLength(1); + expect(h.generated[0]).toMatchObject({ prompt: "生成产品图", model: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4", negativePrompt: "不要文字", promptExtend: false, allowFailover: false, + providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } }); + expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" }); +}); +test("redraw and a corrective retry inherit the same user model, batch and image controls", async () => { + const h = harness(); const tools = await h.build(); + await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1"); + await tools.executable.generateCanvasImages.execute({ prompt: "修正背景", modelId: "system-default" }, "retry-1"); + expect(h.generated).toHaveLength(2); + for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", imageCount: 3, aspectRatio: "3:4", allowFailover: false }); +}); +test("an unavailable explicit choice never silently generates or charges through the default", async () => { + const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" }); + await expect(tools.executable.generateCanvasImages.execute({ prompt: "画图" }, "bad")).rejects.toThrow("指定的图片模型不可用"); + expect(h.generated).toHaveLength(0); expect(h.operations).toHaveLength(0); +}); +test("Auto selection retains routing and the n count alias without overriding a supplied planner ratio", async () => { + const h = harness(); const tools = await h.build({ parameters: { n: 2, imageSize: "8K" } }); + await tools.executable.generateCanvasImages.execute({ prompt: "画图", aspectRatio: "16:9" }, "auto"); + expect(h.generated[0]).toMatchObject({ model: "system-default", imageCount: 2, imageSize: "8K", aspectRatio: "16:9", allowFailover: true }); +}); -function inferTripoImageType(imageUrl: string) { - try { - const extension = new URL(imageUrl).pathname.split(".").pop()?.toLowerCase(); - if (extension === "png" || extension === "webp") return extension; - } catch {} - return "jpg"; -} +test("planning uses the image count for generation tasks but does not turn analysis into an image task", async () => { + const h = harness(); const tools = await h.build(); + const plan = { steps: ["查看画布"], visualCriteria: [] }; + const analysis = await tools.executable.planCanvasTask.execute(plan, "analysis-plan"); + expect(analysis.expectedImageCount).toBeUndefined(); + const generation = await tools.executable.planCanvasTask.execute({ ...plan, expectedImageCount: 1, aspectRatio: "1:1" }, "generation-plan"); + expect(generation).toMatchObject({ expectedImageCount: 3, aspectRatio: "3:4" }); + expect(h.generated).toHaveLength(0); +}); -async function resolveImageModel(modelRef?: unknown) { - const entries = sortStyleProImageModelEntries( - await getStyleProImageModelEntries({ includeHidden: false }) - ); - const requested = findStyleProImageModelByRef(entries, String(modelRef || "")); - const selected = requested || entries.find((entry) => entry.model.isDefault) || entries[0]; - if (!selected) throw new Error("系统未配置可用的图片生成模型"); - return selected; -} +test("an explicit model in the user's words pins generation, while the planner's own choice does not", async () => { + const { resolveUserImageModel } = await import("@/lib/ai/canvas-agent-runtime/image-preferences"); + const entries = [{ model: { id: "pinned", modelId: "nano-banana-pro", displayName: "Banana Pro" } }]; + expect(resolveUserImageModel("请使用 nano banana pro 画一只小猫", entries)).toBe("pinned"); + expect(resolveUserImageModel("不要用nano-banana-pro,自动画图", entries)).toBeUndefined(); + expect(resolveUserImageModel("不用nano-banana-pro,自动画图", entries)).toBeUndefined(); + expect(resolveUserImageModel("先使用nano-banana-pro,改用other-image", [...entries, { model: { id: "later", modelId: "other-image" } }])).toBe("later"); + expect(resolveUserImageModel("画一只小猫", entries)).toBeUndefined(); + expect(resolveUserImageModel("使用nano-banana-pro", entries, "manual-control")).toBe("manual-control"); +}); -async function executeForTarget( - params: HeadlessExecutorParams, - target: CanvasCapabilityTarget -): Promise { - const { command } = params; - const prompt = String(command.args.prompt || "").trim(); - const common = { - sourceNodeId: target.nodeId, - prompt: prompt || undefined, - }; - - if (command.capabilityId === "image.remove_background") { - const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl); - const result = await executeRmbgJob({ - userId: params.userId, - imageBuffer, - sourceImageUrl: target.sourceImageUrl, - prompt: prompt || "抠图处理", - skipGallerySave: false, - }); - return [{ - ...common, - assetId: result.mediaAssetId || undefined, - title: `${target.title}-抠图`, - url: result.original_url, - }]; - } - - if (command.capabilityId === "image.extend") { - const result = await executeExtendJob({ - userId: params.userId, - imageUrl: target.sourceImageUrl, - sourceImageUrl: target.sourceImageUrl, - prompt: prompt || "图像拓展处理", - xScale: asFiniteNumber(command.args.xScale), - yScale: asFiniteNumber(command.args.yScale), - requestId: command.requestId, - }); - return [{ - ...common, - assetId: result.mediaAssetId || undefined, - title: `${target.title}-扩图`, - url: result.original_url, - }]; - } - - if (command.capabilityId === "image.resize") { - const width = asFiniteNumber(command.args.width); - const height = asFiniteNumber(command.args.height); - if (!width || !height) throw new Error("请提供完整的目标宽度和高度"); - const result = await executeResizeJob({ - userId: params.userId, - imageUrl: target.sourceImageUrl, - sourceImageUrl: target.sourceImageUrl, - canvasWidth: Math.round(width), - canvasHeight: Math.round(height), - backgroundColor: String(command.args.backgroundColor || "").trim() || undefined, - transparentBackground: command.args.transparentBackground === true, - mimeType: String(command.args.mimeType || "").trim() || undefined, - }); - return [{ - ...common, - assetId: result.mediaAssetId || undefined, - height: result.canvas_height, - title: `${target.title}-${result.canvas_width}x${result.canvas_height}`, - url: result.original_url, - width: result.canvas_width, - }]; - } - - if (command.capabilityId === "image.box_cutout") { - const result = await executeBoxCutoutJob({ - userId: params.userId, - imageUrl: target.sourceImageUrl, - sourceNodeId: target.nodeId, - cropRect: { - x: asFiniteNumber(command.args.x), - y: asFiniteNumber(command.args.y), - width: asFiniteNumber(command.args.width), - height: asFiniteNumber(command.args.height), - }, - paddingRatio: asFiniteNumber(command.args.paddingRatio), - prompt: prompt || "框选抠图", - }); - return [{ - ...common, - assetId: result.mediaAssetId || undefined, - metadata: { - cropRectApplied: result.cropRectApplied, - paddedCropRectApplied: result.paddedCropRectApplied, - }, - title: `${target.title}-框选抠图`, - url: result.original_url, - }]; - } - - if (command.capabilityId === "image.layer_split") { - const result = await executeLayerSplitJob({ - userId: params.userId, - imageUrl: target.sourceImageUrl, - sourceNodeId: target.nodeId, - prompt: prompt || undefined, - }); - return result.layers.map((layer) => ({ - ...common, - assetId: layer.mediaAssetId || undefined, - metadata: { - bounds: layer.bounds || null, - layerIndex: layer.layerIndex, - model: result.model, - provider: result.provider, - totalLayers: layer.totalLayers, - }, - title: layer.title || `${target.title}-图层${layer.layerIndex + 1}`, - url: layer.originalUrl, - })); - } - - if (command.capabilityId === "image.enhance") { - const result = await requestLongTaskResult({ - endpoint: resolveCanvasRuntimeEndpoint("/api/enhance", params.requestUrl), - payload: { - imageUrl: target.sourceImageUrl, - prompt: prompt || "图像增强处理", - scaleFactor: asFiniteNumber(command.args.scaleFactor), - }, - pollingParams: { - prompt: prompt || "图像增强处理", - sourceImageUrl: target.sourceImageUrl, - }, - timeoutMs: 10 * 60 * 1000, - headers: getCookieHeaders(params.requestHeaders), - }); - if (!result?.success || !result.original_url) { - throw new Error(String(result?.error || result?.detail || "图像增强失败")); - } - return [{ - ...common, - title: `${target.title}-高清增强`, - url: String(result.original_url), - }]; - } - - if (command.capabilityId === "image.remove_watermark") { - const result = await requestLongTaskResult({ - endpoint: resolveCanvasRuntimeEndpoint( - "/api/watermark-remove", - params.requestUrl - ), - payload: { image_url: target.sourceImageUrl }, - timeoutMs: 20 * 60 * 1000, - headers: getCookieHeaders(params.requestHeaders), - }); - if (!result?.success || !result.original_url) { - throw new Error(String(result?.error || result?.detail || "去水印失败")); - } - return [{ - ...common, - title: `${target.title}-去水印`, - url: String(result.original_url), - }]; - } - - if (command.capabilityId === "image.multi_angle") { - const result = await postRuntimeEndpoint({ - path: "/api/angle-edit", - requestUrl: params.requestUrl, - requestHeaders: params.requestHeaders, - body: { - image_url: target.sourceImageUrl, - horizontal_angle: asFiniteNumber(command.args.horizontalAngle) ?? 30, - vertical_angle: asFiniteNumber(command.args.verticalAngle) ?? 0, - zoom: asFiniteNumber(command.args.zoom) ?? 0, - }, - }); - const url = String(result.image_url || result.original_url || "").trim(); - if (!url) throw new Error("多视角服务未返回图片"); - return [{ ...common, title: `${target.title}-新视角`, url }]; - } - - if (command.capabilityId === "image.generate_3d_model") { - const modelVersion = - String(command.args.modelId || command.args.modelVersion || "").trim() || - "tripo-standard"; - const promptText = prompt || target.title || "图生 3D 模型"; - const response = await postRuntimeEndpoint({ - path: "/api/canvas/3d/tasks", - requestUrl: params.requestUrl, - requestHeaders: params.requestHeaders, - timeoutMs: 180_000, - body: { - taskType: "image_to_model", - imageUrl: target.sourceImageUrl, - imageType: inferTripoImageType(target.sourceImageUrl), - prompt: promptText, - modelVersion, - faceLimit: 120_000, - texture: true, - pbr: true, - autoSize: false, - quad: false, - generateParts: false, - smartLowPoly: false, - geometryQuality: "standard", - textureQuality: "standard", - textureAlignment: "original_image", - orientation: "default", - }, - }); - const task = response.data as Record | undefined; - const taskId = String(task?.taskId || "").trim(); - if (!taskId) throw new Error("3D 模型服务未返回任务 ID"); - const providerId = String(task?.providerId || "").trim() || null; - const providerName = String(task?.providerName || "").trim() || null; - return [ - { - ...common, - nodeType: "model3d", - title: `${target.title}-3D模型`, - url: target.sourceImageUrl, - metadata: { - taskId, - provider: "tripo-3d", - mode: "image", - status: "generating", - sourceImageUrl: target.sourceImageUrl, - providerId, - providerName, - modelVersion, - generationStartedAt: new Date().toISOString(), - assetPayload: { - providerId, - providerName, - sourceImageUrl: target.sourceImageUrl, - }, - }, - }, - ]; - } - - if (command.capabilityId === "image.generate_3d_world") { - const modelVersion = - String(command.args.modelId || command.args.modelVersion || "").trim() || - "world-standard"; - const promptText = prompt || target.title || "图生 3D 世界"; - const formData = new FormData(); - formData.append("sourceUrl", target.sourceImageUrl); - formData.append("kind", "image"); - const uploadResponse = await postRuntimeFormData({ - formData, - path: "/api/canvas/world/upload", - requestUrl: params.requestUrl, - requestHeaders: params.requestHeaders, - timeoutMs: 180_000, - }); - const upload = uploadResponse.data as Record | undefined; - const mediaAssetId = String(upload?.mediaAssetId || "").trim(); - if (!mediaAssetId) throw new Error("3D 世界服务未返回媒体资源 ID"); - const providerId = String(upload?.providerId || "").trim() || null; - const providerName = String(upload?.providerName || "").trim() || null; - const taskResponse = await postRuntimeEndpoint({ - path: "/api/canvas/world/tasks", - requestUrl: params.requestUrl, - requestHeaders: params.requestHeaders, - timeoutMs: 240_000, - body: { - mode: "image", - mediaAssetId, - providerId, - prompt: promptText, - displayName: target.title || promptText, - model: modelVersion, - disableRecaption: false, - makePublic: false, - }, - }); - const task = taskResponse.data as Record | undefined; - const taskId = String(task?.taskId || "").trim(); - if (!taskId) throw new Error("3D 世界服务未返回任务 ID"); - const taskAssetPayload = - task?.assetPayload && typeof task.assetPayload === "object" - ? (task.assetPayload as Record) - : {}; - return [ - { - ...common, - nodeType: "world3d", - title: `${target.title}-3D世界`, - url: target.sourceImageUrl, - metadata: { - taskId, - provider: "worldlabs-3d", - mode: "world-image", - status: "generating", - sourceImageUrl: target.sourceImageUrl, - providerId, - providerName, - modelVersion, - generationStartedAt: new Date().toISOString(), - assetPayload: { - ...taskAssetPayload, - providerId, - providerName, - sourceImageUrl: target.sourceImageUrl, - }, - }, - }, - ]; - } - - if ( - command.capabilityId === "image.prompt_regenerate" || - command.capabilityId === "image.ai_wash" - ) { - if (!prompt) throw new Error("请提供重绘提示词"); - const model = await resolveImageModel(command.args.modelId); - if (command.source === "agent" && command.args.agentImageMode === "pinned" && - command.args.modelId !== model.model.id && command.args.modelId !== model.model.modelId) - throw new Error("指定的图片模型不可用,未切换到其他模型"); - const result = await generateCanvasImage({ - prompt, - model: model.model.id, - ...(command.source === "agent" ? { - agentImageMode: command.args.agentImageMode === "pinned" ? "pinned" as const : "auto" as const, - allowFailover: command.args.agentImageMode !== "pinned", - } : {}), - ownerKey: params.userId, - imageCount: 1, - imageSize: String(command.args.imageSize || "2K"), - aspectRatio: String(command.args.aspectRatio || "auto"), - promptExtend: command.args.promptExtend === true, - referenceImage: target.sourceImageUrl, - debugLabel: - command.capabilityId === "image.ai_wash" - ? "canvas-capability-ai-wash" - : "canvas-capability-regenerate", - debugMeta: { - capabilityId: command.capabilityId, - requestId: command.requestId, - sourceNodeId: target.nodeId, - }, - }); - const image = result.images[0]; - if (!image?.originalUrl) throw new Error("图片模型未返回有效结果"); - return [{ - ...common, - title: - command.capabilityId === "image.ai_wash" - ? `${target.title}-AI洗图` - : `${target.title}-重绘`, - url: image.originalUrl, - }]; - } - - throw new Error(`尚未实现后台执行能力:${command.capabilityId}`); -} - -export async function executeCanvasCapabilityHeadlessly( - params: HeadlessExecutorParams -) { - const results: CanvasCapabilityMediaResult[] = []; - for (const target of params.targets) { - results.push(...(await executeForTarget(params, target))); - } - return results; -} +test("mixed-model batches bill and label only the delivered images using their actual models", async () => { + const h = harness([{ originalUrl: "https://test.example/first.png", thumbnailUrl: "https://test.example/first.png", modelRef: "system-default" }, { originalUrl: "https://test.example/second.png", thumbnailUrl: "https://test.example/second.png", modelRef: "backup" }]); + const tools = await h.build({ parameters: { imageCount: 2, imageSize: "2K" } }); + const result = await tools.executable.generateCanvasImages.execute({ prompt: "画图" }, "mixed"); + expect(h.saved).toHaveLength(2); + expect(h.saved.map(call => call[5])).toEqual(["system-default", "backup"]); + expect(h.saved[0][0]).toContain("first.png"); expect(h.saved[0][0]).not.toContain("second.png"); + expect(h.saved[1][0]).toContain("second.png"); + expect(h.operations[0].plan.mediaItems.map((item: any) => item.metadata.model)).toEqual(["system-default", "backup"]); + expect(result.appliedImagePreferences.modelId).toBe("backup"); +}); test("headless image features cannot substitute their default when a pinned model disappears", async () => { let generated = 0; @@ -494,3 +111,11 @@ test("headless image features cannot substitute their default when a pinned mode await expect(executeCanvasCapabilityHeadlessly({ userId: "u", requestHeaders: new Headers(), requestUrl: "https://test.example", command: { capabilityId: "image.ai_wash", source: "agent", args: { prompt: "clean image", modelId: "deleted-model", agentImageMode: "pinned" } }, targets: [{ nodeId: "source", title: "source", sourceImageUrl: "https://test.example/source.png" }] })).rejects.toThrow("指定的图片模型不可用"); expect(generated).toBe(0); }); + + +test("an unavailable planner model recovers automatically with the user's image parameters", async () => { + const h = harness(); const tools = await h.build({ parameters: { imageSize: "4K", aspectRatio: "3:4" } }); + await tools.executable.generateCanvasImages.execute({ prompt: "画图", modelId: "retired-planner-model" }, "auto-retired"); + expect(h.generated).toHaveLength(1); + expect(h.generated[0]).toMatchObject({ model: "system-default", imageSize: "4K", aspectRatio: "3:4", allowFailover: true, agentImageMode: "auto" }); +});