WuYongJie 1b6425b552 feat(desktop): connect native Pi sessions with scoped rules and recovery
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.
2026-10-22 10:49:58 +08:00

127 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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