gongxiangp2/apps/detail-agent-pi/scripts/client-acceptance.mjs
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

103 lines
8.5 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 lockfile from 'proper-lockfile';
import { NativeCanvas } from '../src/native-bridge.mjs';
import { atomic } from '../src/store.mjs';
import { promptChars } from '../src/context-budget.mjs';
const [outputPath, phase] = process.argv.slice(2);
if (!outputPath || !['correction','outline','canvas','long-prepare','long-recall','restart-recall','handover','tool-live'].includes(phase)) throw Error('指定本轮独立验收目录与阶段');
const output = path.resolve(outputPath);
const native = JSON.parse(await fs.readFile(path.join(output, 'native-process.json'), 'utf8'));
const canvas = await NativeCanvas.connect({ port: native.port, tokenFile: path.join(output, 'control.token') });
const flatten = layers => layers.flatMap(layer => { const { children, selected, ...row } = layer; return [row, ...flatten(children || [])]; });
const cmd = (command, params = {}) => canvas.call('engine.execute', { command, params });
const cases = {
correction: '继续这款虚构验收产品:颜色更正为胡桃色,编号和1300×650×760毫米尺寸保持。首屏标题定为「留白有序」。请用memory_set保存更正与标题,简短回复;验收口令「溪杉-6427」只留在这轮历史原文,不写进事实记忆。不修改画布。',
outline: '针对当前项目,只给首屏、六图总览、参数图三屏的简短文案大纲,每屏不超过20字。说明当前店铺、品类与统一字体,按已确认资料,缺失性能不要编造。不要生图,不修改画布。',
canvas: '请实际修改画布:先用canvas_read读取16号文字层,再将文字改为「留白有序」,然后水平向右移动12像素。两步都用canvas_edit实际完成,每一步重新读取当前revision。字号、字体、其他图层均保持,最后简短说明。',
'long-recall': '长历史整理后继续,不改画布。列出这款虚构验收产品的编号、最新颜色、长宽高、已确认首屏标题、当前店铺和品类。再用memory_search查颜色更正那轮:只留在历史、未写进事实记忆的验收口令是什么?不要猜。',
'restart-recall': '客户端已关闭并重新打开。不要修改画布。请恢复本项目编号、长宽高、最新颜色和首屏标题,说明店铺、品类、字体;用memory_search核对之前仅留在历史的验收口令。',
handover: '这是接手验收:先读取16号文字层,然后把它向下移动8像素,再将字号改为114pt。每一步先核对实时revision与allowed;如果用户接手就立即停止后续操作,不要自行重试。',
'tool-live': '这是完整本地包的独立验收,不修改画布、不生图。请先context_read读取当前品牌规则;再自己用tool_develop编写client_padding_1022工具:main(input)返回{padding:Math.round(input.width/12)},并测试width=1440得到120、750得到63、0得到0,全部通过后用tool_run实际计算1440。最后简短给出测试数、运行结果和本项目最新颜色、标题、店铺品类。不要把工具结果当产品性能。',
};
try {
const initial = await canvas.call('jiansun.state');
assert.equal(initial.agentEngine, 'pi');
if (initial.busy || initial.unknown || initial.queued || initial.piReview) throw Error('已有任务,禁止重复派发');
const attempt = path.join(output, `${phase}-attempt.json`);
await fs.writeFile(attempt, JSON.stringify({ at: new Date().toISOString(), phase, text: cases[phase], processId: process.pid, nativePid: native.pid }), { flag: 'wx' });
if (phase === 'long-prepare') {
const account = path.join(native.profile, 'jiansun/accounts');
const scopes = await fs.readdir(account);
assert.equal(scopes.length, 1);
const projects = path.join(account, scopes[0], 'projects');
const ids = await fs.readdir(projects); assert.equal(ids.length, 1);
const projectRoot = path.join(projects, ids[0], 'agent', scopes[0], ids[0]);
const release = await lockfile.lock(projectRoot, { retries: 0 });
try {
const current = JSON.parse(await fs.readFile(path.join(projectRoot, 'current.json'), 'utf8'));
const dir = path.join(projectRoot, current.bindingHash);
const session = JSON.parse(await fs.readFile(path.join(dir, 'session.json'), 'utf8'));
const manager = SessionManager.open(session.file, dir, dir);
let count = 0;
while (promptChars({ messages: manager.buildSessionContext().messages }) < 90000 && count < 100) {
manager.appendMessage({ role: 'user', content: `[构造历史${count},不是新产品事实、不是新用户指令] ` + '只讨论留白、色调与信息顺序,不改变已确认资料,不表示已执行任何画布操作。'.repeat(20), timestamp: Date.now() }); count++;
}
const chars = promptChars({ messages: manager.buildSessionContext().messages });
assert.ok(chars >= 80000 && chars < 110000);
manager.appendCustomEntry('qa-synthetic-client-history', { count, chars, notRealApiDialogue: true });
await atomic(path.join(output, `${phase}-passed.json`), { constructedHistoryMessages: count, chars, sessionFile: session.file, modelPosts: 0 });
console.log(JSON.stringify({ constructedHistoryMessages: count, chars, modelPosts: 0 }));
} finally { await release(); }
process.exitCode = 0;
} else {
const before = await cmd('document.inspect');
const submitted = await canvas.action('agent', { text: cases[phase] });
assert.equal(submitted.submitted, true);
const states = []; let tookOver = false, finished;
const deadline = Date.now() + 300000;
let idleAt = 0;
while (Date.now() < deadline) {
const state = await canvas.call('jiansun.state');
const point = { at: Date.now(), busy: state.busy, pi: state.pi, queued: state.queued, stream: state.streamReply, error: state.error, unknown: state.unknown };
states.push(point);
if (phase === 'handover' && !tookOver && state.queued > 0) { await canvas.action('handover'); tookOver = true; }
if (state.unknown) throw Error('结果未知,停止验收,只读核查原请求;不重发');
if (!state.busy && !state.queued) {
if (!idleAt) idleAt = Date.now();
if (Date.now() - idleAt > 2500) { finished = state; break; }
} else idleAt = 0;
await new Promise(resolve => setTimeout(resolve, phase === 'handover' ? 70 : 200));
}
await atomic(path.join(output, `${phase}-states.json`), states);
if (!finished) throw Error('任务尚未结束,保留原请求;不得重新提交');
assert.equal(finished.error, '');
const reply = finished.messages.filter(m => m.role === 'assistant' && !m.kind).at(-1)?.text || '';
const after = await cmd('document.inspect');
if (phase === 'canvas') {
const old = flatten(before.layers), next = flatten(after.layers);
assert.equal(next.find(l=>l.id===16).text.text, '留白有序');
assert.equal(next.find(l=>l.id===16).text.font, old.find(l=>l.id===16).text.font);
assert.equal(next.find(l=>l.id===16).text.sizePt, old.find(l=>l.id===16).text.sizePt);
assert.deepEqual(next.filter(l=>l.id!==16), old.filter(l=>l.id!==16));
await cmd('edit.undo'); await cmd('edit.undo');
assert.deepEqual(flatten((await cmd('document.inspect')).layers), old);
await cmd('edit.redo'); await cmd('edit.redo');
assert.deepEqual(flatten((await cmd('document.inspect')).layers), next);
} else if (phase === 'handover') {
assert.equal(tookOver, true);
assert.deepEqual(flatten(after.layers), flatten(before.layers));
} else {
assert.deepEqual(flatten(after.layers), flatten(before.layers));
if (phase.includes('recall')) for (const word of ['QA-PI-CLIENT-1022','1300','650','760','胡桃','留白有序','顾家','电脑桌','溪杉-6427']) assert.ok(reply.includes(word), `未恢复${word}`);
}
await canvas.call('ui.screenshot', { path: `${phase}-result.png`, focus: false });
await atomic(path.join(output, `${phase}-passed.json`), { passed: true, reply, usage: finished.piUsage, states: states.length,
partialReplyObserved: states.some(s=>s.busy && s.stream), nativePid: native.pid, processId: process.pid, tookOver, document: { width:after.width,height:after.height,revision:after.revision }, engine:'pi' });
console.log(JSON.stringify({ phase, passed:true, reply, partialReplyObserved:states.some(s=>s.busy && s.stream), usage:finished.piUsage }));
}
} finally { canvas.close(); }