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.
284 lines
23 KiB
JavaScript
284 lines
23 KiB
JavaScript
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);}
|
||
}
|
||
});
|