diff --git a/components/canvas/chat-panel/canvas-agent-progress.tsx b/components/canvas/chat-panel/canvas-agent-progress.tsx index 59a1873d1..9a7bc1e38 100644 --- a/components/canvas/chat-panel/canvas-agent-progress.tsx +++ b/components/canvas/chat-panel/canvas-agent-progress.tsx @@ -27,7 +27,7 @@ export function CanvasAgentProgressMessage(props: {
  • {activity.status === "started" ? : } {canvasAgentProgressLabel(activity.title)} - {activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "待处理" : activity.status === "queued" ? "待执行" : "进行中"} + {activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "未完成" : activity.status === "queued" ? "待执行" : "进行中"}
  • ))} diff --git a/lib/ai/canvas-agent-runtime/progress.ts b/lib/ai/canvas-agent-runtime/progress.ts index 572bff11e..ca50431f3 100644 --- a/lib/ai/canvas-agent-runtime/progress.ts +++ b/lib/ai/canvas-agent-runtime/progress.ts @@ -14,11 +14,12 @@ const oldTitles: Record = { generateCanvasImages: "生成图片", editTargetImage: "编辑图片", readProjectService: "读取所需资料", getWeather: "查询天气", createDocument: "创建文档", updateDocument: "更新文档", requestSuggestions: "整理修改建议", runProjectService: "处理创作内容", listProjectServices: "查找所需功能", loadProjectSkill: "读取技能要求", + controlCanvasView: "调整画布视图", image_add_to_chat: "添加图片到对话", image_rename: "重命名图片", }; export function canvasAgentActivities(snapshot: AgentRunSnapshot) { return snapshot.calls.slice(-12).map(call => ({ id: call.id, - title: canvasAgentProgressLabel((call.title && call.title !== call.name ? call.title : oldTitles[call.name]) || "处理任务"), + title: canvasAgentProgressLabel(oldTitles[call.name] || (call.title && call.title !== call.name ? call.title : "处理任务")), status: call.status, })); } diff --git a/lib/ai/canvas-agent-runtime/project-tools.ts b/lib/ai/canvas-agent-runtime/project-tools.ts index 9f91a28b3..50305a73e 100644 --- a/lib/ai/canvas-agent-runtime/project-tools.ts +++ b/lib/ai/canvas-agent-runtime/project-tools.ts @@ -25,7 +25,8 @@ export async function registerProjectAgentTools(params: { const userId = params.session.user!.id!; function register(name: string, description: string, schema: z.ZodTypeAny, execute: CanvasAgentTool["execute"], readOnly = true) { params.definitions[name] = tool({ description, inputSchema: schema }); - params.executable[name] = { title: description.slice(0, 32), readOnly, concurrentSafe: readOnly, execute }; + const titles: Record = { listProjectServices: "查找所需功能", readProjectService: "读取所需资料", runProjectService: "处理创作内容", loadProjectSkill: "读取技能要求", getWeather: "查询天气", createDocument: "创建文档", updateDocument: "更新文档", requestSuggestions: "整理修改建议" }; + params.executable[name] = { title: titles[name] || "处理项目任务", readOnly, concurrentSafe: readOnly, execute }; } register("listProjectServices", "列出图片画布之外的全部项目业务工具、真实接口及输入提示。可按分组筛选;authorized=false的分镜/视频功能暂不授权。", z.object({ group: z.string().max(100).optional(), operationId: z.string().max(120).optional() }), async input => ({ services: PROJECT_AGENT_OPERATIONS.filter(item => (!input.group || item.group === input.group) && (!input.operationId || item.id === input.operationId)) @@ -37,6 +38,8 @@ export async function registerProjectAgentTools(params: { const args = invokeSchema.parse(input); const operation = PROJECT_AGENT_OPERATIONS.find(item => item.id === args.operationId); if (!operation || operation.readOnly !== readOnly) throw new Error("请使用清单中对应读写类别的固定业务工具"); + if (operation.id === "appearance.post" && !/主题|深色模式|浅色模式|暗黑模式|界面外观|theme|appearance/i.test(params.objective)) + throw new AgentToolRejectedError("本次任务未要求修改界面主题,请继续处理用户要求的图片任务。"); if (params.imagePreferences?.modelId && ["style-generate.post", "wash-generate.post"].includes(operation.id)) throw new AgentToolRejectedError("用户已指定生图模型,请用 generateCanvasImages 执行并保留当前图片参数;不能通过另一条生成接口换用默认模型。"); if (!projectOperationAuthorized(operation)) throw new Error("用户暂未授权视频画布和分镜工具"); diff --git a/lib/ai/canvas-agent-runtime/protocol.ts b/lib/ai/canvas-agent-runtime/protocol.ts index 744aaf00f..7f8a1f01a 100644 --- a/lib/ai/canvas-agent-runtime/protocol.ts +++ b/lib/ai/canvas-agent-runtime/protocol.ts @@ -15,6 +15,8 @@ export type AgentCallRecord = AgentCall & { title?: string; status: "queued" | "started" | "succeeded" | "failed"; uncertain?: boolean; + /** Provider explicitly refused submission; no result exists to reconcile. */ + confirmedNoResult?: boolean; rejected?: boolean; output?: Record; error?: string; diff --git a/lib/ai/canvas-agent-runtime/results.ts b/lib/ai/canvas-agent-runtime/results.ts index e62bb8847..f3a4174d1 100644 --- a/lib/ai/canvas-agent-runtime/results.ts +++ b/lib/ai/canvas-agent-runtime/results.ts @@ -10,6 +10,17 @@ export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallR !/下载|导出|保存.*(?:本地|电脑)|download|export/i.test(intent); } +/** This command merely adds an existing image to the composer. It does not + * open an editor or require the user to supply files/confirm a media operation. */ +export function isCompletedComposerReference(call: AgentCallRecord) { + return call.name === "image_add_to_chat" && call.status === "succeeded" && + call.output?.status === "succeeded" && call.output?.mode === "interactive"; +} + +export function requiresCanvasInteraction(snapshot: AgentRunSnapshot, call: AgentCallRecord) { + return call.output?.mode === "interactive" && !isAncillaryDownload(snapshot, call) && !isCompletedComposerReference(call); +} + export function finalImageResultNodeIds( snapshot: AgentRunSnapshot, nodes: Array<{ id: string; type: string }>, diff --git a/lib/ai/canvas-agent-runtime/review.ts b/lib/ai/canvas-agent-runtime/review.ts index d2503b640..2806bf8f5 100644 --- a/lib/ai/canvas-agent-runtime/review.ts +++ b/lib/ai/canvas-agent-runtime/review.ts @@ -5,7 +5,7 @@ import sharp from "sharp"; import { z } from "zod"; import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas"; import type { AgentRunSnapshot } from "./protocol"; -import { finalResultNodeIds, originalSourceNodeIds, isAncillaryDownload } from "./results"; +import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results"; import { isCompletedSimpleCutout } from "./cutout-completion"; export async function inspectImageAlpha(source: Buffer) { @@ -123,7 +123,7 @@ export async function verifyCanvasAgentResults(params: { const incomplete = calls.filter( (call) => call.status !== "succeeded" || - (call.output?.mode === "interactive" && !isAncillaryDownload(snapshot, call)) || + requiresCanvasInteraction(snapshot, call) || (typeof call.output?.status === "string" && call.output.status !== "succeeded"), ); if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] }; diff --git a/lib/ai/providers/bananaimage.ts b/lib/ai/providers/bananaimage.ts index 5538df3fd..94aa33d5a 100644 --- a/lib/ai/providers/bananaimage.ts +++ b/lib/ai/providers/bananaimage.ts @@ -1,4 +1,5 @@ import "server-only"; +import { ImageSubmissionRejectedError } from "@/lib/ai/runtime/image-generation-error"; import sharp from "sharp"; import { @@ -450,6 +451,8 @@ async function submitGeminiTask({ ); const payload = await parseJsonResponse(response); if (!response.ok) { + if ([400, 401, 403, 413, 415, 422, 429].includes(response.status)) + throw new ImageSubmissionRejectedError(`${extractErrorMessage(payload)}(HTTP ${response.status})`, response.status); throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`); } const taskId = extractTaskId(payload); @@ -529,6 +532,8 @@ async function submitGptImageTask({ }); const payload = await parseJsonResponse(response); if (!response.ok) { + if ([400, 401, 403, 413, 415, 422, 429].includes(response.status)) + throw new ImageSubmissionRejectedError(`${extractErrorMessage(payload)}(HTTP ${response.status})`, response.status); throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`); } const taskId = extractTaskId(payload); diff --git a/lib/ai/runtime/image-generation-error.ts b/lib/ai/runtime/image-generation-error.ts new file mode 100644 index 000000000..b110ced04 --- /dev/null +++ b/lib/ai/runtime/image-generation-error.ts @@ -0,0 +1,30 @@ +/** Only adapters with an explicit rejection of the submission may use this. + * Polling, result downloads and storage failures are not submission refusals. */ +export class ImageSubmissionRejectedError extends Error { + constructor(message: string, readonly status: number) { super(message); } +} + +export class AgentImageGenerationError extends Error { + constructor(message: string, readonly confirmedNoResult: boolean) { super(message); } +} + +/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */ +export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") { + const rejected = error instanceof ImageSubmissionRejectedError; + const message = error instanceof Error ? error.message : ""; + const suffix = mode === "pinned" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。"; + if (rejected && /余额|insufficient.*(?:balance|credit)|credits? insufficient/i.test(message)) + return new AgentImageGenerationError(`生图通道余额不足,本次未生成图片。${suffix}`, true); + if (rejected && error.status === 429) + return new AgentImageGenerationError(`生图通道额度不足或请求过多,本次未生成图片。${suffix}`, true); + if (rejected && [401, 403].includes(error.status)) + return new AgentImageGenerationError(`生图通道鉴权或权限未通过,本次未生成图片。${suffix}`, true); + if (/content.?policy|moderation|safety|违规|敏感/i.test(message)) + return new AgentImageGenerationError("当前图片内容无法处理,请调整描述或参考图后重试。", rejected); + if ((error as { providerOutcomeUnknown?: boolean })?.providerOutcomeUnknown || /may still be running|仍在处理|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset/i.test(message)) + return new AgentImageGenerationError("图片任务的结果暂未确认,已保留任务记录,请先确认原任务结果,避免重复生成。", false); + if (rejected) return new AgentImageGenerationError(`生图通道拒绝了本次请求,未生成图片。${suffix}`, true); + return new AgentImageGenerationError(mode === "pinned" + ? "你指定的生图模型本次未能完成,请稍后重试或手动更换模型;系统未自动切换。" + : "暂时未能完成本次出图,请检查描述、参考图或稍后继续;已有结果会保留。", rejected); +} diff --git a/lib/ai/zcode-agent/task.ts b/lib/ai/zcode-agent/task.ts index 2f7ffaf77..88354b938 100644 --- a/lib/ai/zcode-agent/task.ts +++ b/lib/ai/zcode-agent/task.ts @@ -6,7 +6,8 @@ import { runNativeZcode } from "./native-runtime"; import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop"; import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion"; -import { isAncillaryDownload } from "@/lib/ai/canvas-agent-runtime/results"; +import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results"; +import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; type Ports = { native?: typeof runNativeZcode; @@ -35,6 +36,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise input.applied).slice(-4).map(input => input.text)].join("\n") : `${snapshot.objective}\n\n当前画布与历史对话上下文:${JSON.stringify(ports.context)}`; while (true) { + toolFailure = ""; const control = await exclusive(() => ports.consumeInputs(snapshot)); if (control.cancelled || ports.signal.aborted) return await save("已停止后续处理,完成的结果保留在画布。", "cancelled"); await save("正在准备处理任务"); @@ -123,6 +127,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise call.uncertain)) throw new Error("先核对未确认的操作,禁止重复提交收费任务"); + if (!entry.readOnly && toolFailure) throw new AgentToolRejectedError(toolFailure); if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded")) throw new Error("请先制定任务计划再执行画布修改"); if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) { @@ -173,7 +178,10 @@ export async function runZcodeCanvasTask(ports: Ports): Promise { const call = snapshot.calls.find(call => call.id === callId)!; - call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError; call.uncertain = !entry.readOnly && !call.rejected; + call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError; + call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult; + call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult; + if (call.confirmedNoResult) toolFailure = call.error; await save(call.error); }); throw error; @@ -190,18 +198,19 @@ export async function runZcodeCanvasTask(ports: Ports): Promise (call.status === "failed" && !call.rejected && !ports.tools[call.name]?.readOnly) || call.uncertain)) + if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain)) return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review"); - const unresolvedRead = snapshot.calls.some((call, index) => call.status === "failed" && !call.rejected && + const unresolvedRead = snapshot.calls.some((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly && !snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" && JSON.stringify(later.input) === JSON.stringify(call.input))); // Without a real result to independently inspect, a viewport/UI action // cannot turn a failed analysis into a completed task. if (unresolvedRead && !snapshot.resultNodeIds.length) return await save("图片或资料暂未读取成功,已保留记录,请稍后继续核对。", "needs_review"); - if (question || snapshot.calls.some(call => call.output?.mode === "interactive" && !isAncillaryDownload(snapshot, call))) + if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call))) return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review"); const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id)); if (supplements.length) { prompt = supplements.map(input => input.text).join("\n"); continue; } @@ -220,7 +229,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise !call.rejected && + const reviewSnapshot = { ...snapshot, calls: snapshot.calls.filter(call => !call.rejected && !call.confirmedNoResult && !(call.status === "failed" && ports.tools[call.name]?.readOnly)) }; const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification; snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens; @@ -236,6 +245,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise false); if (verifiedCompletion && !cancelled && !ports.signal.aborted) return finishVerifiedCutout(); - return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review"); + return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review"); } } diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index c0ecc8635..49626c44b 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -4,6 +4,7 @@ import sharp from "sharp"; import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store"; import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing"; import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover"; +import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; @@ -5868,13 +5869,7 @@ export async function generateCanvasImage( } if (request.agentImageMode) { - const message = String((caughtError as { message?: string })?.message || ""); - if (/content.?policy|moderation|safety|违规|敏感/i.test(message)) throw new Error("当前图片内容无法处理,请调整描述或参考图后重试。"); - if ((caughtError as { providerOutcomeUnknown?: boolean })?.providerOutcomeUnknown || /may still be running|仍在处理|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset/i.test(message)) - throw new Error("图片任务的结果暂未确认,已保留任务记录,请先确认原任务结果,避免重复生成。"); - throw new Error(request.agentImageMode === "pinned" - ? "你指定的生图模型本次未能完成,请稍后重试或手动更换模型;系统未自动切换。" - : "暂时未能完成本次出图,请检查描述、参考图或稍后继续;已有结果会保留。"); + throw agentImageGenerationError(caughtError, request.agentImageMode); } throw caughtError; } diff --git a/scripts/zcode-agent/task-fixture.ts b/scripts/zcode-agent/task-fixture.ts index 79e630891..9d03addcc 100644 --- a/scripts/zcode-agent/task-fixture.ts +++ b/scripts/zcode-agent/task-fixture.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { runZcodeCanvasTask } from '../../lib/ai/zcode-agent/task'; import { AgentToolRejectedError, newAgentRunSnapshot, type AgentRunSnapshot } from '../../lib/ai/canvas-agent-runtime/protocol'; import type { runNativeZcode } from '../../services/zcode-agent/runner.mjs'; +import { AgentImageGenerationError } from '../../lib/ai/runtime/image-generation-error'; type NativeOptions = Parameters[0]; function fixture(objective = '修改图片') { const snapshot = newAgentRunSnapshot(objective, []); let writes = 0; const saved: string[] = []; @@ -23,6 +24,36 @@ function fixture(objective = '修改图片') { const result = { type: 'result', sessionId: 'sess_fixture', response: '已完成' }; async function main() { let checks = 0; +{ + const h = fixture();let paid = 0; + h.ports.tools.edit.execute = async () => { paid++; throw new AgentImageGenerationError('生图通道余额不足,本次未生成图片。你希望切换模型还是稍后重试?', true); }; + h.ports.native = async options => { + await options.execute('planCanvasTask', {}, 'plan', options.signal!); + await assert.rejects(options.execute('edit', {nodeIds:['a']}, 'refused', options.signal!)); + await assert.rejects(options.execute('edit', {nodeIds:['a']}, 'do-not-silently-retry', options.signal!)); + return result; + }; + assert.equal(await runZcodeCanvasTask(h.ports), 'needs_review'); assert.equal(paid,1); + const call=h.snapshot.calls.find(c=>c.id==='refused')!; + assert.equal(call.confirmedNoResult,true); assert.equal(call.uncertain,false); assert.match(h.snapshot.message,/余额不足.*切换模型/); + // A later user-confirmed retry may proceed, while keeping the old failure in + // history and independently checking the newly returned result. + h.ports.tools.edit.execute=async()=>{paid++;return {createdNodeIds:['real-result']};}; + h.ports.native=async options=>{await options.execute('edit',{nodeIds:['a']},'user-retry',options.signal!);return result;}; + h.ports.verify=async snapshot=>{assert(!snapshot.calls.some(c=>c.confirmedNoResult));return {passed:true,issues:[]};}; + assert.equal(await runZcodeCanvasTask(h.ports),'completed'); assert.equal(paid,2);checks++; +} +{ + const h = fixture(); h.ports.tools.edit.execute=async()=>{throw new AgentImageGenerationError('原任务结果未知,禁止重复提交',false);}; + h.ports.native=async options=>{await options.execute('planCanvasTask',{},'plan',options.signal!);await options.execute('edit',{nodeIds:['a']},'unknown',options.signal!);return result;}; + assert.equal(await runZcodeCanvasTask(h.ports),'needs_review'); assert.equal(h.snapshot.calls.at(-1)?.uncertain,true); + let submitted=false;h.ports.native=async()=>{submitted=true;return result;}; + assert.equal(await runZcodeCanvasTask(h.ports),'needs_review');assert.equal(submitted,false);checks++; +} +{ + const h=fixture();h.snapshot.calls.push({id:'reference',name:'image_add_to_chat',input:{},status:'succeeded',output:{mode:'interactive',status:'succeeded'}}); + assert.equal(await runZcodeCanvasTask(h.ports),'completed');checks++; +} for (const scenario of ["verified", "rejected-result", "download-requested", "no-result", "editor-panel"]) { const h = fixture(scenario === "download-requested" ? "修改并下载图片" : "把柜子角度改为正面"); if (scenario !== "no-result") h.snapshot.resultNodeIds = ["real-result"]; diff --git a/tests/helpers/agent-image-provider-harness.ts b/tests/helpers/agent-image-provider-harness.ts index 85adddfca..3f6805b4f 100644 --- a/tests/helpers/agent-image-provider-harness.ts +++ b/tests/helpers/agent-image-provider-harness.ts @@ -1,9 +1,11 @@ import { serverLoader } from "./canvas-download-server-loader"; +import * as generationErrors from "@/lib/ai/runtime/image-generation-error"; export function agentImageProviderHarness(options: { generate?: (id: string, count: number, call: number) => string; models?: any[] } = {}) { const calls: any[] = [], paused = new Set(); const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } })); const mocks: Record = { + "@/lib/ai/runtime/image-generation-error": generationErrors, "@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models }, "@/lib/ai/rmbg/service": {}, "@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) }, diff --git a/tests/routes/canvas-agent-runtime-image-failover.test.ts b/tests/routes/canvas-agent-runtime-image-failover.test.ts index b000cfd5a..78530d06c 100644 --- a/tests/routes/canvas-agent-runtime-image-failover.test.ts +++ b/tests/routes/canvas-agent-runtime-image-failover.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "@playwright/test"; import { agentImageProviderHarness as harness } from "../helpers/agent-image-provider-harness"; import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover"; +import { ImageSubmissionRejectedError, agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; +import * as generationErrors from "@/lib/ai/runtime/image-generation-error"; +import { serverLoader } from "../helpers/canvas-download-server-loader"; test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => { @@ -92,3 +95,31 @@ test("a parameter rejection can recover on a compatible alternate without pausin expect(h.paused.size).toBe(0); expect(h.calls[1].imageParams.aspectRatio).toBe("3:4"); }); + +test("a pinned quota refusal remains a confirmed failure and asks before switching", async () => { + const h = harness({ generate: () => { throw new ImageSubmissionRejectedError("余额不足(HTTP 429)", 429); } }); + await expect(h.generate({ ...h.request, agentImageMode: "pinned" })).rejects.toMatchObject({ + confirmedNoResult: true, message: expect.stringMatching(/余额不足.*未生成图片.*切换生图模型.*稍后重试/), + }); + expect(h.calls.map(c => c.id)).toEqual(["primary"]); +}); + +test("timeouts and post-generation failures never acquire submission refusal evidence", () => { + for (const error of [new Error("task timed out"), new Error("结果图片下载失败(HTTP 429)"), new Error("storage failed"), Object.assign(new Error("HTTP 503"), { status: 503 })]) + expect(agentImageGenerationError(error, "pinned").confirmedNoResult).toBe(false); +}); + +test("both BananaImage submission APIs mark quota refusal before polling or archiving", async () => { + for (const id of ["gemini-3-pro-image-preview", "gpt-image-2"]) { + let requests = 0, archived = 0; + const { createBananaImage } = serverLoader({ + "@/lib/ai/runtime/image-generation-error": generationErrors, + "@/lib/storage/image-archive": { createDeferredUploadedImage: () => { archived++; throw new Error("unexpected archive"); } }, + "@/lib/storage/service": {}, + fetch: async () => { requests++; return Response.json({ error: { message: "余额不足" } }, { status: 429 }); }, + })("lib/ai/providers/bananaimage.ts"); + await expect(createBananaImage({ apiKey: "synthetic", baseURL: "https://fixture.invalid" }).chat(id).doGenerate({ messages: [{ role: "user", content: "cat" }] })) + .rejects.toBeInstanceOf(ImageSubmissionRejectedError); + expect(requests).toBe(1);expect(archived).toBe(0); + } +}); diff --git a/tests/routes/canvas-agent-runtime-progress.test.tsx b/tests/routes/canvas-agent-runtime-progress.test.tsx index 90c6ea86e..16736c9cc 100644 --- a/tests/routes/canvas-agent-runtime-progress.test.tsx +++ b/tests/routes/canvas-agent-runtime-progress.test.tsx @@ -42,6 +42,15 @@ test("progress renders as inline assistant content with one stop action and no s expect(html).toContain("生成图片"); expect(html).toContain("停止后续步骤"); expect(html).not.toMatch(/ZCode|沙箱|sandbox|Auto Agent| { + const snapshot=newAgentRunSnapshot("改图",[]); + snapshot.calls.push({id:"refused",name:"runProjectService",title:"执行固定项目业务接口。先查清单和真实数据、制定计划;仅当前用户权限",input:{},status:"failed"}); + const activities=canvasAgentActivities(snapshot); + expect(activities[0].title).toBe("处理创作内容"); + const run={...fixture(),status:"needs_review" as const,message:"通道余额不足,请选择其他模型或稍后重试",activities}; + const html=renderToStaticMarkup(createElement(CanvasAgentProgressMessage,{progress:canvasAgentProgress(run)})); + expect(html).toContain("未完成");expect(html).not.toContain("待处理");expect(html).not.toContain("operationId");expect(html).not.toContain("固定项目业务接口"); +}); test("completed execution details remain expandable in saved assistant history without active controls", () => { const run = { ...fixture(), status: "completed" as const }; const part = JSON.parse(JSON.stringify({ type: "data-canvasAgentProgress", data: canvasAgentProgress(run) })); diff --git a/tests/routes/canvas-agent-runtime-review.test.ts b/tests/routes/canvas-agent-runtime-review.test.ts index 77359c543..574aa1076 100644 --- a/tests/routes/canvas-agent-runtime-review.test.ts +++ b/tests/routes/canvas-agent-runtime-review.test.ts @@ -198,6 +198,13 @@ async function cutoutFixture() { return { ...f, bytes }; } +test("a completed add-to-conversation command does not keep a generated image waiting for an editor", async () => { + const f = await fixture('{"passed":true,"issues":[],"failedNodeIds":[]}'); + f.params.snapshot.calls.push({ id: "reference", name: "image_add_to_chat", input: {}, status: "succeeded", output: { mode: "interactive", status: "succeeded" } }); + const result = await verifyCanvasAgentResults(f.params); + expect(result.passed).toBe(true); +}); + 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);