236 lines
16 KiB
TypeScript

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<typeof runNativeZcode>[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;
for (const passed of [true,false]) {
const h=fixture();let paid=0,reviews=0;
h.ports.tools.edit.execute=async()=>{paid++;return {updatedNodeIds:['a'],createdNodeIds:['real-result']};};
h.ports.native=async options=>{await options.execute('planCanvasTask',{},'plan',options.signal!);await options.execute('edit',{nodeIds:['a']},'generated',options.signal!);throw new Error('planner connection lost after result');};
h.ports.verify=async()=>{reviews++;return {passed,issues:passed?[]:['主体不符合要求']};};
assert.equal(await runZcodeCanvasTask(h.ports),passed?'completed':'needs_review');assert.equal(paid,1);assert.equal(reviews,1);
assert.match(h.snapshot.message,passed?/已完成并通过检查/:/结果待核对.*主体/);checks++;
}
{
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 {updatedNodeIds:['a'],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; });