gongxiangp2/apps/detail-agent-pi/test/workflow.test.mjs
WuYongJie fb4212eb85 feat(client): connect Pi Agent to the shared creation workflow
Add live workflow reads, native workspace navigation and reviewed action cards. Reuse existing creation, template-slot and export actions with account/project/version checks, busy handoff, manual takeover and original unknown-request protections. Refresh product evidence without duplicating rule context and invalidate pending actions after manual changes.

Validated: Pi 63/63, native UI 122/122, backend 172 passed with 5 environment tests ignored; strict Clippy, layering and wasm checks. One native window and real selected Grok 4.7 completed live reads/navigation with 3 complete model calls, 0 retries and 0 images. Full paid generation and Agent-driven PSD export are outside this acceptance.
2026-10-23 23:21:27 +08:00

284 lines
23 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 test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { createAssistantMessageEventStream, getCurrentSystemPrompt } from '@earendil-works/pi-ai/compat';
import { SessionManager } from '@earendil-works/pi-coding-agent';
import { DetailPiAgent } from '../src/agent.mjs';
import { ProjectStore, atomic } from '../src/store.mjs';
import { StdioCanvas } from '../src/stdio-canvas.mjs';
import { NativeCanvas } from '../src/native-bridge.mjs';
import { workflowContext, workflowHistory, workflowResult } from '../src/workflow-context.mjs';
import { cleanup } from './cleanup.mjs';
const scope = '8'.repeat(64);
const binding = { brandId:'qa',shopId:'qa',categoryId:'desk',platform:'native',version:'1',source:'explicit-local-test',rules:[] };
const info = { accountScope:scope,accountId:'local-qa',origin:'https://platform.test',model:{id:'qa-workflow',protocol:'openai-chat'},pending:[] };
const prefix = '当前项目规则快照与已确认事实(每次调用重新注入,以此为准):\n';
function injection(context) {
const text = getCurrentSystemPrompt(context.messages);
return JSON.parse(text.slice(text.lastIndexOf(prefix) + prefix.length).split('\n')[0]);
}
function stream(content) {
const out = createAssistantMessageEventStream();
const message = { role:'assistant',api:'openai-completions',provider:'jiansun-native-98k',model:info.model.id,content,
stopReason:content.some(c=>c.type==='toolCall')?'toolUse':'stop',timestamp:Date.now(),
usage:{input:1,output:1,cacheRead:0,cacheWrite:0,totalTokens:2,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}} };
queueMicrotask(()=>{out.push({type:'start',partial:message});out.push({type:'done',reason:message.stopReason,message});out.end(message);});
return out;
}
const tool = (name,args,id='workflow-call') => [{type:'toolCall',id,name,arguments:args}];
const answer = text => [{type:'text',text}];
function evidence(project,color='胡桃色',brandId='native-brand',categoryId='native-category',length='') {
return {accountScope:scope,project,documentId:7,revision:1,brief:{name:'电脑桌',brandId,categoryId,colorName:color,dimensions:{length,width:'',height:'',unit:'mm'}},
facts:{status:'ready',items:[{text:'长方形桌面',source:'产品实拍'}],missing:['木种和承重未确认']},sellingPoints:{selected:[],batches:[]},plan:{status:'idle',screens:[]}};
}
function baseCanvas() {
return {activities:[],writes:0,grants:0,async begin(){},async read(){return {conversationActive:true,allowed:false,writeBlocked:false,revision:1,context:{id:7,name:'本机画布',width:1440,height:1800,layers:[]}};},
async requestWrite(){this.grants++;throw Error('not used');},async edit(){this.writes++;throw Error('not used');},async activity(text){this.activities.push(text);},async delta(){},async end(){}};
}
async function fixture(reply,{adapter='full',broker,history=false,nativeEvidence=true,nativeBrandId='native-brand',nativeCategoryId='native-category'}={}) {
const root=await fs.mkdtemp(path.join(os.tmpdir(),'jiansun-pi-')),project=randomUUID(),identity={scope,project};
const store=await ProjectStore.open(root,identity,binding,{continuous:true}),canvas=baseCanvas();
if(history){
const manager=SessionManager.create(store.dir,path.join(store.dir,'sessions'));
for(let i=0;i<12;i++){
manager.appendMessage({role:'user',content:`本地旧讨论${i}:`+'资料'.repeat(500),timestamp:Date.now()});
manager.appendMessage({role:'assistant',api:'openai-completions',provider:'jiansun-native-98k',model:info.model.id,content:answer('本地mock历史说明。'.repeat(100)),stopReason:'stop',timestamp:Date.now(),usage:{input:5000,output:500,cacheRead:0,cacheWrite:0,totalTokens:5500,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}}});
}
await atomic(path.join(store.dir,'session.json'),{file:manager.getSessionFile()});
}
const native={version:1,stage:'materials',color:'胡桃色',brandId:nativeBrandId,categoryId:nativeCategoryId,length:'',reads:[],navigations:[],proposals:[],generated:0};
if(adapter!=='legacy')canvas.workflowRead=async args=>{
native.reads.push(structuredClone(args));
return {available:adapter!=='unavailable',version:native.version,stage:native.stage,section:args.section||'overview',revisions:{canvas:1,creation:native.version,slots:0},
productEvidence:adapter==='unavailable'?null:evidence(project,native.color,native.brandId,native.categoryId,native.length),outputs:[{id:'detail',name:'详情',count:5}]};
};
if(adapter==='full'){
canvas.workflowNavigate=async args=>{native.navigations.push(args);native.stage=args.stage;return {available:true,version:native.version,stage:native.stage,outputId:args.outputId};};
canvas.workflowPropose=async args=>{native.proposals.push(args);if(args.version!==native.version)throw Error('工作流版本已变化,请重新读取;未添加建议');if(args.instruction?.trim()&&args.intent!=='selling_points')throw Error('补充要求仅用于卖点表达方向,其他提案使用当前配置');return {proposed:true,awaitingReview:true,executed:false,version:native.version,proposal:{...args,title:'拟定页面方案',awaitingReview:true,executed:false}};};
}
const calls=[];
const transport={info,stream(_model,context){calls.push({context,injected:injection(context)});return stream(reply(context,calls.length,native,canvas));}};
const host=await DetailPiAgent.open({store,canvas,transport,broker,projectEvidence:nativeEvidence?evidence(project,'胡桃色',native.brandId,native.categoryId):undefined,documentId:7});
return {root,project,identity,store,canvas,native,calls,host,async close(){await host.dispose();await cleanup(root);}};
}
test('each SDK step reads live workflow and product state while suggestions stay review-only',async()=>{
const x=await fixture((_context,n,native)=>{
if(n===1)return tool('workflow_read',{section:'materials'},'read-materials');
if(n===2)return tool('workflow_navigate',{stage:'plan',outputId:'detail'},'show-plan');
if(n===3)return tool('workflow_propose',{version:native.version,intent:'plan',outputId:'detail'},'propose-plan');
return answer('已添加待审核建议,尚未生成页面。');
});
const read=x.canvas.workflowRead;
x.canvas.workflowRead=async args=>{
if(args.section==='materials'){x.native.version=2;x.native.color='米白色';x.native.length='1400';}
return read(args);
};
try{
const result=await x.host.prompt('先看当前资料,再展示方案步骤并列出建议,暂不执行。');
assert.match(result.text,/待审核/);
assert.equal(x.calls.length,4);
assert.equal(x.calls[0].injected.currentProduct.product.colorName,'胡桃色');
for(const call of x.calls.slice(1)){
assert.equal(call.injected.currentWorkflow.version,2);
assert.equal(call.injected.currentProduct.product.colorName,'米白色');
assert.equal(call.injected.currentProduct.dimensions.length,'1400');
const system=getCurrentSystemPrompt(call.context.messages);
assert.equal(system.split('"currentProduct":').length-1,1);
assert.equal(system.split('"currentWorkflow":').length-1,1);
assert.ok(!JSON.stringify(call.injected.currentWorkflow).includes('productEvidence'));
}
assert.equal(x.native.reads.filter(v=>v.section==='overview').length,7);
assert.equal(x.native.navigations.length,1);assert.equal(x.native.proposals.length,1);assert.equal(x.native.generated,0);
assert.equal(x.canvas.writes,0);assert.equal(x.canvas.grants,0);
for(const label of ['读取创作进度','切换创作视图','添加待审核建议'])assert.ok(x.canvas.activities.some(a=>a.includes(label)));
const proposal=x.host.session.messages.find(m=>m.role==='toolResult'&&m.toolName==='workflow_propose');
const card=JSON.parse(proposal.content[0].text);
assert.equal(card.executed,false);assert.equal(card.proposal.intent,'plan');assert.equal(card.proposal.outputId,'detail');
assert.equal(card.proposal.title,'拟定页面方案');assert.equal(card.proposal.awaitingReview,true);
}finally{await x.close();}
});
test('missing workflow adapters keep old tools compatible; unavailable live data never revives an old product',async()=>{
for(const adapter of ['legacy','unavailable']){
const x=await fixture(()=>answer('仅讨论当前可确认的资料。'),{adapter});
try{
await x.host.prompt('先聊一下当前资料。');
const names=x.host.session.getActiveToolNames();
assert.equal(names.includes('workflow_read'),adapter!=='legacy');
assert.ok(!names.includes('workflow_navigate'));assert.ok(!names.includes('workflow_propose'));
if(adapter==='legacy')assert.equal(x.calls[0].injected.currentProduct.product.colorName,'胡桃色');
else{assert.equal(x.calls[0].injected.currentWorkflow.available,false);assert.equal(x.calls[0].injected.currentProduct.state,'unavailable');assert.ok(!JSON.stringify(x.calls[0].injected.currentProduct).includes('胡桃色'));}
}finally{await x.close();}
}
});
test('a stale proposal stays an SDK tool error and is never retried or executed',async()=>{
const x=await fixture((context,n)=>{
if(n===1)return tool('workflow_propose',{version:1,intent:'analyze'},'stale-proposal');
const failure=context.messages.find(m=>m.role==='toolResult'&&m.toolName==='workflow_propose');
assert.equal(failure.isError,true);assert.match(failure.content[0].text,/工作流版本已变化/);
return answer('资料已更新,原建议未添加,也未执行分析。');
});
x.native.version=2;
try{await x.host.prompt('先提出分析建议。');assert.equal(x.native.proposals.length,1);assert.equal(x.native.generated,0);assert.equal(x.calls.length,2);}
finally{await x.close();}
});
test('foreign live evidence rejects before inference and memory_set cannot mutate native product data',async()=>{
const foreign=await fixture(()=>{throw Error('must not infer with foreign evidence');});
foreign.canvas.workflowRead=async()=>({available:true,version:1,section:'overview',productEvidence:{...evidence(foreign.project),accountScope:'9'.repeat(64)}});
try{await assert.rejects(foreign.host.prompt('读取资料。'),/不属于当前账户和项目/);assert.equal(foreign.calls.length,0);}
finally{await foreign.close();}
const x=await fixture((_context,n)=>n===1?tool('memory_set',{key:'color',value:'米白色',evidence:'颜色是米白色'},'remember-only'):answer('已记录讨论内容,产品资料尚未修改。'));
try{
await x.host.prompt('我在讨论中确认颜色是米白色,请先记住。');
assert.equal((await x.store.context()).confirmedMemory.color.value,'米白色');
assert.equal(x.calls[1].injected.currentProduct.product.colorName,'胡桃色');
assert.equal(x.native.color,'胡桃色');assert.equal(x.native.proposals.length,0);
}finally{await x.close();}
});
test('brand or category changes stop before inference or tools and require a new rule-bound turn',async()=>{
for(const key of ['brandId','categoryId'])for(const phase of ['before-provider','before-tool']){
const x=await fixture((_context,n,native)=>{
if(n===1){native[key]='changed-native-id';return tool('memory_set',{key:'color',value:'米白色',evidence:'颜色是米白色'},'must-not-remember');}
throw Error('must not infer with a new product and old rules');
},{nativeBrandId:'',nativeCategoryId:''});
let reopened,disposed=false;
try{
if(phase==='before-provider')x.native[key]='changed-native-id';
const ledger=path.join(x.store.memoryDir,'user-evidence.jsonl');
await assert.rejects(x.host.prompt('我确认颜色是米白色,请记录事实。'),/品牌或品类已变化/);
assert.equal(x.calls.length,phase==='before-provider'?0:1);
assert.deepEqual((await x.store.context()).confirmedMemory,{});
if(phase==='before-provider')await assert.rejects(fs.stat(ledger),{code:'ENOENT'});
assert.equal(x.canvas.writes,0);assert.equal(x.canvas.grants,0);assert.equal(x.native.proposals.length,0);
const calls=x.calls.length;
await assert.rejects(x.host.prompt('继续刚才的讨论。'),/品牌或品类已变化/);
assert.equal(x.calls.length,calls);
await x.host.dispose();disposed=true;
const updated=await ProjectStore.open(x.root,x.identity,{...binding,brandId:'new-rule-brand',categoryId:'new-rule-category',version:'2',rules:['新回合已加载新规则']},{continuous:true});
const next=[];
reopened=await DetailPiAgent.open({store:updated,canvas:x.canvas,documentId:7,projectEvidence:evidence(x.project,x.native.color,x.native.brandId,x.native.categoryId),
transport:{info,stream(_model,context){next.push(injection(context));return stream(answer('已按新回合的规则继续讨论。'));}}});
await reopened.prompt('按当前产品重新讨论,不修改画布。');
assert.equal(next.length,1);assert.equal(next[0].snapshot.brandId,'new-rule-brand');assert.equal(next[0].snapshot.categoryId,'new-rule-category');
assert.equal(next[0].currentProduct.product[key],'changed-native-id');
assert.deepEqual(next[0].confirmedMemory,{});assert.equal(x.canvas.writes,0);
}finally{await reopened?.dispose();if(!disposed)await x.host.dispose();await cleanup(x.root);}
}
// An adapter without initial native evidence may establish its first live
// binding, while a native empty ID above remains a real immutable binding.
const initial=await fixture(()=>answer('首次读取的本机资料已可用于讨论。'),{nativeEvidence:false});
try{await initial.host.prompt('读取当前资料。');assert.equal(initial.calls.length,1);assert.equal(initial.calls[0].injected.currentProduct.product.brandId,'native-brand');}
finally{await initial.close();}
});
test('proposal instructions describe only selling-point direction and other intents reject them without executing',async()=>{
const x=await fixture((context,n,native)=>{
if(n===1){assert.match(getCurrentSystemPrompt(context.messages),/instruction仅用于selling_points/);return tool('workflow_propose',{version:native.version,intent:'plan',instruction:'改为12屏、750px'},'invalid-plan-instruction');}
if(n===2){const result=context.messages.find(m=>m.role==='toolResult'&&m.toolCallId==='invalid-plan-instruction');assert.equal(result.isError,true);assert.match(result.content[0].text,/补充要求仅用于卖点/);return tool('workflow_propose',{version:native.version,intent:'selling_points',instruction:'面向小户型,保持事实依据。'},'points-direction');}
return answer('已列出待审核的卖点方向,尚未生成或修改页面配置。');
});
try{
await x.host.prompt('先讨论卖点方向,不改当前页面尺寸。');
assert.equal(x.native.proposals.length,2);assert.equal(x.native.generated,0);assert.equal(x.canvas.writes,0);
const result=x.host.session.messages.find(m=>m.role==='toolResult'&&m.toolCallId==='points-direction');
const proposal=JSON.parse(result.content[0].text).proposal;
assert.equal(proposal.intent,'selling_points');assert.equal(proposal.instruction,'面向小户型,保持事实依据。');assert.equal(proposal.executed,false);
}finally{await x.close();}
});
test('unsaved native drafts keep prior analysis stale and compaction does not refresh or navigate the UI',async()=>{
const x=await fixture(()=>answer('草稿尚未保存,旧分析不能作为新依据;先等待用户核对。'),{history:true});
const read=x.canvas.workflowRead;
x.canvas.workflowRead=async args=>{
const value=await read(args);
value.uiDirty=true;value.dirty=true;value.draftDirty=true;value.writeBlocked=false;value.factsStatus='stale';value.planConfirmed=false;
value.selected=['batch:0'];value.batchCount=1;
value.capabilities={imageDispatchAllowed:false,copyDispatchAllowed:false,localApplyAllowed:false};
value.productEvidence.facts.status='stale';
value.productEvidence.sellingPoints={selected:['batch:0'],batches:[{id:'batch',items:['旧卖点']}],stale:true};
value.productEvidence.plan.status='stale';
return value;
};
try{
await x.host.prompt('讨论当前还没保存的资料草稿。');
assert.equal(x.calls[0].injected.currentWorkflow.uiDirty,true);
assert.equal(x.calls[0].injected.currentWorkflow.draftDirty,true);
assert.equal(x.calls[0].injected.currentWorkflow.writeBlocked,false);
assert.equal(x.calls[0].injected.currentWorkflow.factsStatus,'stale');
assert.equal(x.calls[0].injected.currentWorkflow.planConfirmed,false);
assert.equal(x.calls[0].injected.currentWorkflow.batchCount,1);
assert.deepEqual(x.calls[0].injected.currentWorkflow.selected,['batch:0']);
assert.deepEqual(x.calls[0].injected.currentWorkflow.capabilities,{imageDispatchAllowed:false,copyDispatchAllowed:false,localApplyAllowed:false});
assert.equal(x.calls[0].injected.currentProduct.factsStatus,'stale');
assert.deepEqual(x.calls[0].injected.currentProduct.selectedSellingPoints,[]);
assert.equal(x.calls[0].injected.currentProduct.plan.status,'stale');
assert.match(getCurrentSystemPrompt(x.calls[0].context.messages),/version只用于上下文一致性,不代表方案已确认/);
const reads=x.native.reads.length;
await x.host.compact();
assert.equal(x.native.reads.length,reads);
assert.equal(x.native.navigations.length,0);assert.equal(x.native.proposals.length,0);assert.equal(x.native.generated,0);
}finally{await x.close();}
});
test('workflow projection is bounded and data-only while history compaction preserves original receipts',()=>{
const value={available:true,version:9,section:'slots',slots:Array.from({length:100},(_,i)=>({id:'slot-'+i,name:'槽位'+i,text:'可确认摘要'.repeat(100),source:'C:\\private\\original.png'})),
imagePrompt:'PRIVATE_LONG_PROMPT'.repeat(1000),path:'D:\\private\\project',images:['data:image/png;base64,PRIVATE_PIXELS'],previewUrl:'https://private.example/image'};
const safe=workflowResult(value),text=JSON.stringify(safe);
assert.ok(text.length<=8000);assert.ok(safe.omitted.entries>0);
for(const secret of ['PRIVATE_LONG_PROMPT','PRIVATE_PIXELS','D:\\private','C:\\private','https://private'])assert.ok(!text.includes(secret));
const nativeShape={available:true,version:9,section:'materials',brief:{name:'电脑桌',brandId:'brand',categoryId:'desk',identifierType:'SKU',identifier:'TABLE-1',colorName:'胡桃色',dimensions:{length:'1400',width:'600',height:'750',unit:'mm'},note:'实拍核对'},
draftDirty:true,factsStatus:'stale',planConfirmed:false,writeBlocked:true,width:1440,screenCount:5,selected:['batch:0'],batchCount:1,
capabilities:{imageDispatchAllowed:false,copyDispatchAllowed:false,localApplyAllowed:true},
slots:[{id:'slot',candidates:[{id:'candidate',variant:'B',reused:true}]}],
outputs:[{documentId:'M01',selectedImages:1,hasEditedDraft:true}]};
assert.deepEqual(workflowResult(nativeShape),nativeShape);
assert.deepEqual(workflowResult({navigated:true,stage:'plan',outputId:'detail',version:9,executed:false}),{navigated:true,stage:'plan',outputId:'detail',version:9,executed:false});
const raw={messages:[1,9].map((version,i)=>({role:'toolResult',toolName:'workflow_read',toolCallId:'read-'+i,content:[{type:'text',text:JSON.stringify({available:true,version,section:'slots',items:[{text:i?'latest':'OLD_LARGE_WORKFLOW'.repeat(500)}]})}]}))};
raw.messages.push({role:'toolResult',toolName:'workflow_propose',toolCallId:'proposal',content:[{type:'text',text:JSON.stringify({version:9,proposed:true,awaitingReview:true,executed:false,proposal:{version:9,intent:'export',instruction:'长建议'.repeat(100),outputId:'main_images',slotId:'main-slot',variant:'B',awaitingReview:true,executed:false}})}]});
const original=JSON.stringify(raw),projected=workflowHistory(raw,{version:9});
assert.ok(!JSON.stringify(projected).includes('OLD_LARGE_WORKFLOW'));assert.ok(JSON.stringify(projected).includes('latest'));
const receipt=JSON.parse(projected.messages[2].content[0].text);
assert.equal(receipt.executed,false);assert.equal(receipt.intent,'export');assert.equal(receipt.outputId,'main_images');assert.equal(receipt.slotId,'main-slot');assert.equal(receipt.variant,'B');
assert.equal(JSON.stringify(raw),original);
assert.throws(()=>workflowContext({...value,version:Number.MAX_SAFE_INTEGER+1},{scope,project:'p',documentId:7}),/工作流版本/);
});
test('stdio and socket adapters route only the three typed workflow actions without network',async()=>{
const sent=[];
const stdio=new StdioCanvas(async(action,input)=>{sent.push({action,input});return {ok:true};},7);stdio.run='same-run';
await stdio.workflowRead({section:'plan'});await stdio.workflowNavigate({stage:'slots',outputId:'sku_images'});await stdio.workflowPropose({version:3,intent:'export'});
assert.deepEqual(sent.map(q=>q.action),['pi-workflow-read','pi-workflow-navigate','pi-workflow-propose']);
assert.ok(sent.every(q=>q.input.run==='same-run'&&q.input.documentId===7));
const socket=Object.create(NativeCanvas.prototype),calls=[];socket.run='socket-run';socket.call=async(method,params)=>{calls.push({method,params});return {};};
await socket.workflowRead({section:'overview'});await socket.workflowNavigate({stage:'canvas'});await socket.workflowPropose({version:3,intent:'review_plan'});
assert.ok(calls.every(q=>q.method==='jiansun.action'&&q.params.input.run==='socket-run'));
assert.deepEqual(calls.map(q=>q.params.action),['pi-workflow-read','pi-workflow-navigate','pi-workflow-propose']);
});
test('recovery can restore workflow_read but never replays navigation or review proposals',async()=>{
for(const name of ['workflow_read','workflow_navigate','workflow_propose']){
const x=await fixture(()=>{throw Error('recovery must never infer');});
await x.host.dispose();
const id=randomUUID(),args=name==='workflow_read'?{section:'overview'}:name==='workflow_navigate'?{stage:'plan'}:{version:1,intent:'plan'};
await x.store.appendEvent({type:'provider_prepared',id,bindingHash:x.store.hash});
let phase='received',posts=0;
const original={id:'original-'+name,model:info.model.id,choices:[{message:{role:'assistant',content:'',tool_calls:[{id:'original-tool',type:'function',function:{name,arguments:JSON.stringify(args)}}]},finish_reason:'tool_calls'}]};
const broker={info:async()=>({...info,pending:[]}),read:async()=>({phase,terminal:true,model:info.model,raw:Buffer.from(JSON.stringify(original)).toString('base64'),bodyHash:'original-body',requestId:'original-request'}),confirm:async()=>{phase='complete';},round(){posts++;throw Error('must never post');}};
const host=await DetailPiAgent.open({store:x.store,broker,canvas:x.canvas,projectEvidence:evidence(x.project),documentId:7});
try{
const result=await host.recover(id,{executeReadOnly:true});assert.equal(result.reposted,false);assert.equal(posts,0);
if(name==='workflow_read'){assert.equal(result.readOnlyToolsRecovered,1);assert.equal(x.native.reads.length,1);}
else{assert.equal(result.needsToolReview,true);assert.equal(result.toolsExecuted,0);}
assert.equal(x.native.navigations.length,0);assert.equal(x.native.proposals.length,0);assert.equal(x.native.generated,0);
}finally{await host.dispose();await cleanup(x.root);}
}
});