import assert from 'node:assert/strict'; import { tool } from 'ai'; 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[] = []; const schema = z.object({ nodeIds: z.array(z.string()).default([]) }); const definitions = { planCanvasTask: tool({ inputSchema: z.object({}) }), edit: tool({ inputSchema: schema }), readCanvas: tool({ inputSchema: z.object({}) }) }; const ports = { runId: crypto.randomUUID(), snapshot, definitions, instructions: '', context: {}, gateway: { modelId: 'fixture', apiType: 'openai-chat-completions', forward: async () => Response.json({}) }, signal: new AbortController().signal, checkpoint: async (_snapshot: unknown, status: string) => { saved.push(status); }, consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false, verify: async (_snapshot: AgentRunSnapshot) => ({ passed: true, issues: [] as string[] }), tools: { planCanvasTask: { title: '计划', readOnly: true, concurrentSafe: true, execute: async () => ({ steps: ['修改图片'] }) }, readCanvas: { title: '读取', readOnly: true, concurrentSafe: true, execute: async () => ({ nodes: [] }) }, edit: { title: '修改', readOnly: false, concurrentSafe: false, execute: async () => { writes++; return { updatedNodeIds: ['a'] }; } } }, native: async (options: NativeOptions) => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await options.execute('edit', { nodeIds: ['a'] }, 'edit', options.signal!); return result; } }; return { ports, snapshot, saved, writes: () => writes }; } 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"]; h.snapshot.calls.push({ id: "delivery", name: scenario === "editor-panel" ? "image_crop" : "image_download", input: {}, status: "succeeded", output: { mode: "interactive" } }); let reviews = 0; h.ports.verify = async () => { reviews++; return { passed: scenario !== "rejected-result", issues: scenario === "rejected-result" ? ["结果不是正面"] : [] }; }; assert.equal(await runZcodeCanvasTask(h.ports), scenario === "verified" ? "completed" : "needs_review"); assert.equal(reviews, ["verified", "rejected-result"].includes(scenario) ? 1 : 0); checks++; } { const h = fixture(); h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await options.execute('edit', { nodeIds: ['a'] }, 'same', options.signal!); await options.execute('edit', { nodeIds: ['a'] }, 'same', options.signal!); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.writes(), 1); checks++; } { const h = fixture(); h.ports.native = async options => { await assert.rejects(options.execute('edit', { nodeIds: ['a'] }, 'early', options.signal!), /计划/); await options.execute('planCanvasTask', {}, 'plan', options.signal!); await assert.rejects(options.execute('edit', { nodeIds: 42 }, 'invalid', options.signal!), /参数无效/); await options.execute('edit', { nodeIds: ['a'] }, 'valid', options.signal!); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.writes(), 1); checks++; } { const h = fixture(); h.snapshot.calls.push({ id: 'unknown', name: 'edit', input: {}, status: 'started' }); assert.equal(await runZcodeCanvasTask(h.ports), 'needs_review'); assert.equal(h.writes(), 0); checks++; } { const h = fixture(); h.snapshot.verification = { passed: false, issues: ['bad b'], retryable: true, failedNodeIds: ['b'] }; h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await assert.rejects(options.execute('edit', { nodeIds: ['a','b'] }, 'cross', options.signal!), /未通过/); await options.execute('edit', { nodeIds: ['b'] }, 'only-failed', options.signal!); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.writes(), 1); checks++; } { const h = fixture('画布里有什么'); h.ports.native = async () => ({ ...result, response: '画布为空' }); assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.snapshot.message, '画布为空'); checks++; } { const h = fixture('抠图'); h.ports.native = async () => result; assert.equal(await runZcodeCanvasTask(h.ports), 'needs_review'); assert.equal(h.writes(), 0); checks++; } { const h = fixture(); h.ports.isCancelled = async () => true; assert.equal(await runZcodeCanvasTask(h.ports), 'cancelled'); assert.equal(h.writes(), 1); checks++; } { const h = fixture(); let attempts = 0; h.ports.tools.edit.execute = async () => { attempts++; if (attempts === 1) throw new AgentToolRejectedError('参数未通过,未提交'); return { updatedNodeIds: ['a'] }; }; h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await assert.rejects(options.execute('edit', { nodeIds: ['a'] }, 'rejected', options.signal!)); await options.execute('edit', { nodeIds: ['a'] }, 'corrected', options.signal!); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.snapshot.calls.find(c=>c.id==='rejected')?.uncertain,false); checks++; } { const h = fixture('画布里有什么'); h.snapshot.nativeRuntime = { engine: 'zcode', sessionId: 'previous-native-session' }; h.snapshot.imagePreferences = { modelId: 'user-selected-image-model', parameters: { imageSize: '4K', aspectRatio: '3:4', imageCount: 3, seed: 0, watermark: false } }; h.ports.native = async options => { assert.equal(options.sessionId, 'previous-native-session'); assert.ok(options.prompt.includes('user-selected-image-model')); assert.ok(options.prompt.includes('4K') && options.prompt.includes('3:4')); assert.ok(options.instructions.includes('不得被技能默认值')); return { ...result, response: '已读取本轮图片设置' }; }; assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); checks++; } { const h = fixture('帮我将它扣出来保持透明背景'); let paid = 0; let aborted = false; const ports = { ...h.ports, definitions: { ...h.ports.definitions, removeBackground: tool({ inputSchema: z.object({ nodeIds: z.array(z.string()) }) }) }, tools: { ...h.ports.tools, planCanvasTask: { ...h.ports.tools.planCanvasTask, execute: async () => ({ expectedImageCount: 1 }) }, removeBackground: { title: '抠图去背', readOnly: false, concurrentSafe: false, execute: async () => { paid++; return { status: 'succeeded', createdNodeIds: ['cutout'] }; } } }, verify: async () => ({ passed: true, issues: [] as string[], method: 'alpha-channel' as const }), native: async (options: NativeOptions) => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await options.execute('removeBackground', { nodeIds: ['a'] }, 'cutout', options.signal!); aborted = options.signal!.aborted; throw new Error('native stopped after verified completion'); } }; assert.equal(await runZcodeCanvasTask(ports), 'completed');assert.equal(paid,1);assert.equal(aborted,true); assert.equal(h.snapshot.nativeRuntime?.state,'stopped');assert.match(h.snapshot.message,/抠图已完成/);checks++; } { const h = fixture('抠图后下载');let aborted = false; const ports = { ...h.ports, definitions: { ...h.ports.definitions, removeBackground: tool({ inputSchema: z.object({ nodeIds: z.array(z.string()) }) }) }, tools: { ...h.ports.tools, planCanvasTask: { ...h.ports.tools.planCanvasTask, execute: async () => ({ expectedImageCount: 1 }) }, removeBackground: { title: '抠图去背', readOnly: false, concurrentSafe: false, execute: async () => ({ status: 'succeeded', createdNodeIds: ['cutout'] }) } }, native: async (options: NativeOptions) => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await options.execute('removeBackground', { nodeIds: ['a'] }, 'cutout', options.signal!); aborted = options.signal!.aborted;return result; } }; assert.equal(await runZcodeCanvasTask(ports), 'completed');assert.equal(aborted,false);checks++; } { const h = fixture(); let reads = 0, reviews = 0; h.ports.tools.readCanvas.execute = async () => { if (++reads === 1) throw new Error('The operation was aborted due to timeout'); return { nodes: [] }; }; h.ports.verify = async snapshot => { reviews++; assert(!snapshot.calls.some(call => call.status === 'failed')); return { passed: true, issues: [] }; }; h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await options.execute('edit', { nodeIds: ['a'] }, 'edit', options.signal!); await assert.rejects(options.execute('readCanvas', {}, 'read-timeout', options.signal!)); await options.execute('readCanvas', {}, 'read-recovered', options.signal!); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.writes(), 1); assert.equal(reviews, 1); assert.equal(h.snapshot.calls.find(call => call.id === 'read-timeout')?.status, 'failed'); checks++; } { const h = fixture('画布里有什么'); h.ports.tools.readCanvas.execute = async () => { throw new Error('read timeout'); }; h.ports.native = async options => { await assert.rejects(options.execute('readCanvas', {}, 'read', options.signal!)); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'needs_review'); assert.equal(h.writes(), 0); checks++; } { const h = fixture('画布里有什么'); let reads = 0; h.ports.tools.readCanvas.execute = async () => { if (++reads === 1) throw new Error('read timeout'); return { nodes: [] }; }; h.ports.native = async options => { await assert.rejects(options.execute('readCanvas', {}, 'failed-read', options.signal!)); await options.execute('readCanvas', {}, 'recovered-read', options.signal!); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.writes(), 0); checks++; } { const h = fixture(); h.ports.tools.readCanvas.execute = async () => { throw new Error('read timeout'); }; h.ports.verify = async () => ({ passed: false, issues: ['实际结果不符合要求'] }); h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await options.execute('edit', { nodeIds: ['a'] }, 'edit', options.signal!); h.snapshot.resultNodeIds = ['real-result']; await assert.rejects(options.execute('readCanvas', {}, 'failed-read', options.signal!)); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'needs_review'); assert.match(h.snapshot.message, /实际结果不符合要求/); checks++; } { const h = fixture('分析画布'); let reviews = 0; h.ports.tools.readCanvas.execute = async () => { throw new Error('read timeout'); }; h.ports.verify = async () => { reviews++; return { passed: true, issues: [] }; }; h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await assert.rejects(options.execute('readCanvas', {}, 'failed-read', options.signal!)); await options.execute('edit', { nodeIds: ['a'] }, 'only-ui-action', options.signal!); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'needs_review'); assert.equal(reviews, 0); checks++; } { const h = fixture(); h.ports.tools.edit.execute = async () => { throw new Error('paid request outcome unknown'); }; h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); await assert.rejects(options.execute('edit', { nodeIds: ['a'] }, 'uncertain-paid', options.signal!)); await options.execute('readCanvas', {}, 'read', options.signal!); return result; }; assert.equal(await runZcodeCanvasTask(h.ports), 'needs_review'); assert.equal(h.snapshot.calls.find(call => call.id === 'uncertain-paid')?.uncertain, true); checks++; } console.log('NATIVE_TASK_ADAPTER_PASSED', { checks }); } main().catch(error => { console.error(error); process.exitCode = 1; });