Track the fixed PhotoCraft source baseline with the existing native client integration. Use the actual Pi SDK for normal sends, durable session recovery, demand-loaded enterprise rules and checked undoable canvas tools. Retain request identities and never resend unknown inference. Include local packaging and real-call acceptance evidence.
127 lines
13 KiB
JavaScript
127 lines
13 KiB
JavaScript
import fs from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import assert from 'node:assert/strict';
|
||
import { SessionManager } from '@earendil-works/pi-coding-agent';
|
||
import { ProjectStore, atomic } from '../src/store.mjs';
|
||
import { Native98kBroker, NativeCanvas } from '../src/native-bridge.mjs';
|
||
import { DetailPiAgent } from '../src/agent.mjs';
|
||
|
||
const configFile = process.argv[2];
|
||
const phase = process.argv[3] || 'probe';
|
||
if (!configFile) throw Error('用法: node scripts/acceptance.mjs <本地配置路径> <probe|memory|long|compact|restart|tool|canvas|handover|shop>');
|
||
const config = JSON.parse(await fs.readFile(configFile, 'utf8'));
|
||
const sample = config.sample || { sku: 'QA-PI-6209', dimensions: '1200×600×750', marker: '青岚-4827' };
|
||
const record = path.join(config.output, `accepted-${phase}.json`);
|
||
if (await fs.access(record).then(() => true, () => false)) throw Error('本阶段已有验收记录,禁止重复调用;先查看原记录');
|
||
const broker = new Native98kBroker(config.broker), info = await broker.info();
|
||
const binding = phase === 'shop' ? config.otherBinding : ['canvas', 'canvas-finish', 'handover'].includes(phase) && config.canvasBinding ? config.canvasBinding : config.binding;
|
||
const store = await ProjectStore.open(config.output, { scope: info.accountScope, project: config.broker.project }, binding);
|
||
let canvas;
|
||
if (['canvas', 'canvas-finish', 'handover'].includes(phase)) canvas = await NativeCanvas.connect(config.canvas);
|
||
let host;
|
||
const events = [];
|
||
try {
|
||
let canvasSteps = 0;
|
||
host = await DetailPiAgent.open({ store, broker, canvas, protocolMode: config.protocolMode, onEvent: async event => {
|
||
events.push({ ...event, at: Date.now() });
|
||
if (event.type !== 'text_delta') console.log(JSON.stringify(event));
|
||
if (canvas && phase.startsWith('canvas') && event.type === 'tool_execution_end' && event.toolName === 'canvas_edit' && !event.isError) {
|
||
canvasSteps++;
|
||
await canvas.call('ui.screenshot', { path: path.join(config.output, `pi-${phase}-step-${canvasSteps}.png`), focus: false });
|
||
}
|
||
} });
|
||
let result;
|
||
if (phase === 'probe') result = await host.prompt('本地 Pi 接口连通验收,只回复「本地 Pi 已连接」,不要调用工具。');
|
||
else if (phase === 'memory') {
|
||
result = await host.prompt(`这是本地虚构产品验收资料,不用于真实商品。测试桌产品编号 ${sample.sku};颜色是米白色;尺寸是${sample.dimensions}毫米。请用memory_set分别记住编号、颜色和尺寸。起始历史检索标记是「${sample.marker}」,这个标记只保留在对话里,不要写入memory。完成后简短回复。`);
|
||
const memory = (await store.context()).confirmedMemory;
|
||
assert.ok(Object.values(memory).some(m => m.value.includes('米白')));
|
||
assert.ok(Object.values(memory).some(m => m.value.includes(sample.sku)));
|
||
} else if (phase === 'memory-protocol') {
|
||
result = await host.prompt('前一次你只输出了调用工具的文字,工具并未执行,本次请通过正式function call实际执行,不要假装完成。测试桌产品编号 QA-PI-6209;颜色是米白色;尺寸是1200×600×750毫米。请memory_set记住这三项。', { requiredTool: 'memory_set' });
|
||
const memory = (await store.context()).confirmedMemory;
|
||
assert.ok(Object.values(memory).some(m => m.value.includes('米白')));
|
||
assert.ok(Object.values(memory).some(m => m.value.includes('6209')));
|
||
assert.ok(Object.values(memory).some(m => m.value.includes('1200')));
|
||
} else if (phase === 'long') {
|
||
await host.dispose(); host = null;
|
||
const saved = await store.get('session.json'), manager = SessionManager.open(saved.file, store.dir, store.dir);
|
||
const seeded = manager.getEntries().some(e => e.type === 'custom' && e.customType === 'qa_history_pressure');
|
||
if (!seeded) {
|
||
for (let i = 1; i <= 40; i++) {
|
||
manager.appendMessage({ role: 'user', content: `[本地插入的历史压力夹具 ${i},非真实用户对话] `.repeat(8) + '本段仅检查会话容量,不改变产品资料和任何规则。', timestamp: Date.now() });
|
||
manager.appendMessage({ role: 'assistant', api: 'openai-completions', provider: 'jiansun-native-98k', model: info.model.id, content: [{ type: 'text', text: `[人工构造的历史回复 ${i},非真实模型输出] `.repeat(8) }], stopReason: 'stop', timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } });
|
||
}
|
||
manager.appendCustomEntry('qa_history_pressure', { insertedMessages: 80, actualModelCalls: 0 });
|
||
}
|
||
host = await DetailPiAgent.open({ store, broker, protocolMode: config.protocolMode });
|
||
result = await host.prompt('更正:这个测试桌的颜色改为胡桃色,之前米白色作废。请先更新已有颜色memory键,然后告诉我产品编号、尺寸、最新颜色,以及最开始那条消息中的历史检索标记。不要修改图纸,没有确认的性能不要补。');
|
||
assert.ok(result.text.includes(sample.sku)); assert.match(result.text, /胡桃/); assert.ok(result.text.includes(sample.marker));
|
||
assert.ok(Object.values((await store.context()).confirmedMemory).some(m => m.value.includes('胡桃')));
|
||
result.insertedHistoryMessages = 80;
|
||
} else if (phase === 'compact') {
|
||
result = await host.compact();
|
||
assert.ok(host.manager.getEntries().some(e => e.type === 'compaction'));
|
||
} else if (phase === 'restart') {
|
||
result = await host.prompt('我们继续。不要让我重新交资料。先用memory_search按“起始历史检索标记”查最早原文,再列出目前产品编号、长宽高、有效颜色、历史检索标记;再说清当前店铺和品类规则。简短回答,不编没有提供的参数。');
|
||
assert.ok(result.text.includes(sample.sku)); assert.match(result.text, /胡桃/); assert.ok(result.text.includes(sample.marker));
|
||
assert.ok(events.some(e => e.toolName === 'memory_search' && e.type === 'tool_execution_end' && !e.isError));
|
||
} else if (phase === 'restart-recall') {
|
||
result = await host.prompt('继续上次项目。用memory_search查询“起始历史检索标记”,找到最早原话后直接回答当前产品编号、长宽高、有效颜色、最初标记,以及当前店铺/品类。相同证据不要反复搜索。');
|
||
assert.ok(result.text.includes(sample.sku)); assert.ok(result.text.includes(sample.marker)); assert.match(result.text, /胡桃/);
|
||
assert.ok(result.rounds <= 3);
|
||
} else if (phase === 'tool') {
|
||
result = await host.prompt('现在需要一个现有工具没有的“尺寸格式化”工具。请你自己编写并用tool_develop提交:名字format_dimensions,function main(input)接收{length,width,height,unit},unit支持mm和cm,将长宽高转换为毫米,按四舍五入整数返回{label:"长1200 × 宽600 × 高750 mm"}这样的结果;缺字段、非有限数、非正数返回{error:"invalid_dimensions"},单位不支持返回{error:"invalid_unit"}。你自己设计至少5项测试,包含毫米、厘米、缺值、零值、非法单位,测试全部通过再用tool_run实际运行{length:120,width:60,height:75,unit:"cm"},展示结果。这不是让你调用宿主终端。');
|
||
assert.ok(events.some(e => e.toolName === 'tool_develop' && e.type === 'tool_execution_end' && !e.isError));
|
||
assert.ok(events.some(e => e.toolName === 'tool_run' && e.type === 'tool_execution_end' && !e.isError));
|
||
assert.match(result.text, /1200/); assert.match(result.text, /750/);
|
||
} else if (phase === 'tool-hardening') {
|
||
result = await host.prompt('这是独立的工具质量检查,不涉及画布。format_dimensions原版本通过6项自测并运行成功,但独立检查发现parseFloat会把"12oops"当作12,输入null会抛错,极大数字换算后可能变成Infinity。请自己重新编写该工具、补充测试。严格保留成功结果格式:{label:"长1200 × 宽600 × 高750 mm"},不要改成formatted或增加original字段。输入非null对象,长宽高必须是number、有限正数;unit必须严格为mm或cm,不支持的单位/单位类型返回{error:"invalid_unit"};输入null、缺字段、不合格数值、转换或四舍五入后不是有限正整数一律返回{error:"invalid_dimensions"}。使用tool_develop提交至少9项实际可通过测试,数值0.05cm四舍五入是1mm,不是500mm。再tool_run运行{length:120,width:60,height:75,unit:"cm"}。完成后只展示运行结果,不做其他事情。');
|
||
assert.ok(events.some(e => e.toolName === 'tool_develop' && e.type === 'tool_execution_end' && !e.isError));
|
||
const { ToolLab } = await import('../src/tool-lab.mjs'); const lab = new ToolLab(store.dir);
|
||
assert.deepEqual((await lab.run('format_dimensions', { length: '12oops', width: 6, height: 7, unit: 'cm' })).result, { error: 'invalid_dimensions' });
|
||
assert.deepEqual((await lab.run('format_dimensions', null)).result, { error: 'invalid_dimensions' });
|
||
assert.deepEqual((await lab.run('format_dimensions', { length: 1e308, width: 6, height: 7, unit: 'cm' })).result, { error: 'invalid_dimensions' });
|
||
assert.deepEqual((await lab.run('format_dimensions', { length: 120, width: 60, height: 75, unit: 'cm' })).result, { label: '长1200 × 宽600 × 高750 mm' });
|
||
assert.match(result.text, /1200/);
|
||
} else if (phase === 'canvas') {
|
||
result = await host.prompt('这是本地画布操作验收。先用canvas_read看1440px多屏画布,找到首屏S01/S01-T02产品品类名,把这四字文字改为“温润质感”,字号、颜色和产品图片保持不变。再读取新revision,将这一图层水平向右移动12px。请逐步实际执行,不要只给建议。完成后报告两个操作。');
|
||
assert.ok(events.filter(e => e.toolName === 'canvas_edit' && e.type === 'tool_execution_end' && !e.isError).length >= 2);
|
||
await canvas.call('ui.screenshot', { path: path.join(config.output, 'pi-canvas-after.png'), focus: false });
|
||
} else if (phase === 'canvas-finish') {
|
||
result = await host.prompt('继续这个项目。首屏S01/S01-T02的文字已成功改为“温润质感”,不要重复改字。上一条模型请求已结束但指令JSON不完整,所以向右移动这一步没有执行。请先canvas_read核实当前画布及revision,再将同一个图层仅向右移动12px(dx=12,dy=0),保持字号、颜色、文字和其他图层。完成后直接回复。');
|
||
assert.equal(events.filter(e => e.toolName === 'canvas_edit' && e.type === 'tool_execution_end' && !e.isError).length, 1);
|
||
await canvas.call('ui.screenshot', { path: path.join(config.output, 'pi-canvas-after.png'), focus: false });
|
||
} else if (phase === 'handover') {
|
||
let intercepted = false;
|
||
host.onEvent = event => events.push(event);
|
||
const unsubscribe = host.session.agent.subscribe(async e => {
|
||
if (e.type === 'tool_execution_start' && e.toolName === 'canvas_edit' && !intercepted) {
|
||
intercepted = true;
|
||
const ui = await canvas.call('jiansun.state');
|
||
assert.equal(ui.agentPanel.panel[0], 1081); // checked against the actual 1440×900 native capture
|
||
await canvas.call('ui.click', { x: 1208, y: 595 });
|
||
assert.equal((await canvas.read()).allowed, false);
|
||
}
|
||
});
|
||
const before = await canvas.call('engine.execute', { command: 'document.inspect', params: {} });
|
||
result = await host.prompt('继续刚恢复的任务。第16号S01/S01-T02文字层已通过原请求的只读步骤取回。请使用当前版本号将它的文字改为“清爽工作角”,只改这个文字层。本轮已获得我的编辑授权,请实际调用canvas_edit。');
|
||
const after = await canvas.call('engine.execute', { command: 'document.inspect', params: {} });
|
||
unsubscribe();
|
||
assert.ok(intercepted); assert.deepEqual(after.layers, before.layers);
|
||
await canvas.call('ui.screenshot', { path: path.join(config.output, 'pi-handover.png'), focus: false });
|
||
} else if (phase === 'shop') {
|
||
result = await host.prompt('不要调用工具。只列当前店铺、品类,以及此组合唯一的规则口令。若不知道真实商品参数,请写待确认,不能沿用其他店铺的资料。');
|
||
assert.match(result.text, /B店|QA-B/); assert.match(result.text, /海盐-7391/);
|
||
assert.ok(!result.text.includes(sample.sku) && !result.text.includes(sample.marker));
|
||
assert.doesNotMatch(result.text, /青岚|6209|晨雾-2168/);
|
||
assert.deepEqual((await store.context()).confirmedMemory, {});
|
||
} else throw Error('未知验收阶段');
|
||
const accepted = { phase, model: info.model.id, protocol: info.model.protocol, at: new Date().toISOString(), result, events, bindingHash: store.hash, sessionFile: host.manager.getSessionFile(), sdk: '@earendil-works/pi-coding-agent@1.3.0' };
|
||
await atomic(record, accepted);
|
||
console.log(JSON.stringify({ phase, model: info.model.id, accepted: true, result }, null, 2));
|
||
} catch (error) {
|
||
await atomic(path.join(config.output, `failed-${phase}-${Date.now()}.json`), { phase, error: error.message, events, sessionFile: host?.manager.getSessionFile(), inflight: host?.inflight });
|
||
throw error;
|
||
} finally { await host?.dispose(); canvas?.close(); }
|