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);