From 0ce9a95a9c71dd3629c42f434dd9eb8bf58ac463 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 6 Nov 2026 09:35:35 +0800 Subject: [PATCH] fix(agent): pause failed paid steps and preserve repair references --- .../image-access-tools.ts | 5 +- lib/ai/zcode-agent/task.ts | 48 ++++++---- playwright.canvas-agent.config.ts | 2 +- .../helpers/canvas-download-server-loader.ts | 1 + tests/routes/agent-capability-access.test.ts | 4 +- tests/routes/zcode-agent-task.test.ts | 95 +++++++++++++++++++ 6 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 tests/routes/zcode-agent-task.test.ts diff --git a/lib/ai/canvas-agent-runtime/image-access-tools.ts b/lib/ai/canvas-agent-runtime/image-access-tools.ts index b4e0dcb48..341cfaaa7 100644 --- a/lib/ai/canvas-agent-runtime/image-access-tools.ts +++ b/lib/ai/canvas-agent-runtime/image-access-tools.ts @@ -9,6 +9,7 @@ import { summarizeAgentCapabilities, } from "./capability-access"; import type { CanvasAgentTool } from "./loop"; +import { AgentToolRejectedError } from "./protocol"; import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; export function registerImageCanvasAccessTools(params: { @@ -59,10 +60,10 @@ export function registerImageCanvasAccessTools(params: { (item) => item.workspace === "canvas" && item.id === input.featureId, ); if (!feature?.authorized || !feature.id.startsWith("canvas.ui.")) - throw new Error("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具"); + throw new AgentToolRejectedError("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具;本次未打开面板或提交处理。"); const { nodes } = await readOwnedCanvas(params.userId, params.projectId); if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId))) - throw new Error("目标节点不属于当前画布"); + throw new AgentToolRejectedError("目标节点不属于当前画布,本次未打开面板或提交处理。"); params.emit("data-canvasAgentUiCommand", { projectId: params.projectId, requestId: `${params.runId}:${id}`, diff --git a/lib/ai/zcode-agent/task.ts b/lib/ai/zcode-agent/task.ts index 06250f1c4..9fc2d247c 100644 --- a/lib/ai/zcode-agent/task.ts +++ b/lib/ai/zcode-agent/task.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs"; 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 { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; +import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error"; import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion"; import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results"; import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; @@ -37,6 +38,17 @@ export async function runZcodeCanvasTask(ports: Ports): Promise(); let lastTextMessage = ""; async function appendText(delta: string) { - delta = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0))); - if (!delta) return; - snapshot.assistantText = (snapshot.assistantText || "") + delta; + const text = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0))); + if (!text) return; + snapshot.assistantText = (snapshot.assistantText || "") + text; await ports.checkpoint(structuredClone(snapshot), "running"); - ports.onAssistantText?.(delta); + ports.onAssistantText?.(text); } async function finishVerifiedCutout() { snapshot.nativeRuntime!.state = "stopped"; @@ -56,8 +68,8 @@ export async function runZcodeCanvasTask(ports: Ports): Promise call.status === "started" || call.uncertain)) - return await save("存在结果未确认的工具调用,请先核对画布,避免重复生成。", "needs_review"); + const unconfirmed = snapshot.calls.find(call => call.status === "started" || call.uncertain); + if (unconfirmed) return await save(failureMessage(unconfirmed), "needs_review"); snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" }; const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)])); const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name, @@ -111,6 +123,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise { + if (attemptController.signal.aborted) return; if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") { // Child sessions and reasoning/tool-argument deltas are private. if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return; @@ -155,9 +168,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise call.name === "planCanvasTask" && call.status === "succeeded")) throw new Error("请先制定任务计划再执行画布修改"); if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) { - const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : []), - ...(Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [])].filter((id): id is string => typeof id === "string"); - if (!targets.length || targets.some(id => !snapshot.verification!.failedNodeIds!.includes(id))) + const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : [])] + .filter((id): id is string => typeof id === "string"); + const references = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : []; + const failedIds = snapshot.verification.failedNodeIds; + if (targets.length ? targets.some(id => !failedIds.includes(id)) : !references.some(id => failedIds.includes(String(id)))) throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果"); } snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" }); @@ -205,8 +220,9 @@ export async function runZcodeCanvasTask(ports: Ports): Promise (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 && ports.tools[call.name]?.readOnly && + const failedWrite = snapshot.calls.find(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain); + if (failedWrite) return await save(failureMessage(failedWrite), "needs_review"); + const unresolvedRead = snapshot.calls.find((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"); + return await save(failureMessage(unresolvedRead), "needs_review"); 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)); diff --git a/playwright.canvas-agent.config.ts b/playwright.canvas-agent.config.ts index b7accc168..82e180ca2 100644 --- a/playwright.canvas-agent.config.ts +++ b/playwright.canvas-agent.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests/routes", - testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access)\.test\.tsx?/, + testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access|zcode-agent-task)\.test\.tsx?/, fullyParallel: true, retries: 0, workers: 2, diff --git a/tests/helpers/canvas-download-server-loader.ts b/tests/helpers/canvas-download-server-loader.ts index e75ac07cd..62f154e9e 100644 --- a/tests/helpers/canvas-download-server-loader.ts +++ b/tests/helpers/canvas-download-server-loader.ts @@ -50,6 +50,7 @@ export function serverLoader(mocks: Record, env: Record events.push(args) }); - await expect(executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel")).rejects.toThrow("图片画布界面入口"); + const error = await executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel").catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(load("lib/ai/canvas-agent-runtime/protocol.ts").AgentToolRejectedError); + expect(error.message).toContain("图片画布界面入口"); expect(events).toHaveLength(0); }); test("access manifest covers all configured video functions and node/UI actions without granting fake backends", () => { diff --git a/tests/routes/zcode-agent-task.test.ts b/tests/routes/zcode-agent-task.test.ts new file mode 100644 index 000000000..669bd4c86 --- /dev/null +++ b/tests/routes/zcode-agent-task.test.ts @@ -0,0 +1,95 @@ +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(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); +}); -- 2.47.2