76 lines
6.8 KiB
JavaScript
76 lines
6.8 KiB
JavaScript
/** Run against a disposable API with SANDBOX_PROVIDER=fake and a fresh DB.
|
|
* LAZYBOY_TEST_API=http://127.0.0.1:3118 node --test tests/task-experience.test.mjs
|
|
* The mock provider is reachable from the API at host.docker.internal:3119.
|
|
* Never point this test at a workspace containing real user data.
|
|
*/
|
|
import {test} from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
import {randomUUID} from 'node:crypto';
|
|
const base=process.env.LAZYBOY_TEST_API;
|
|
test('one assignment: verified completion, recovery, and same-run takeover resume',{skip:!base,timeout:60000},async()=>{
|
|
assert.equal(new URL(base).port,'3118','use the isolated test API');
|
|
let cookie='';const calls={};const histories={};
|
|
const report=(state,summary,remaining=[],extra={})=>({state,summary,completed:['Compared the two supplied source excerpts'],remaining,verification:'Checked both supplied excerpts and the requested comparison',artifacts:[],...extra});
|
|
const fixture=http.createServer(async(req,res)=>{
|
|
let body='';for await(const chunk of req)body+=chunk;
|
|
const input=JSON.parse(body||'{}');const serialized=JSON.stringify(input.messages||[]);
|
|
const scenario=['UX_RESEARCH','UX_RECOVERY','UX_TAKEOVER'].find(tag=>serialized.includes(tag));
|
|
if(!scenario){res.writeHead(400);res.end('unknown fixture');return;}
|
|
const index=calls[scenario]||0;calls[scenario]=index+1;(histories[scenario]??=[]).push(serialized);
|
|
let name,args,text='Finished.';
|
|
if(scenario==='UX_RESEARCH'){
|
|
if(index===0)text='I have only planned it.\n[GOAL_COMPLETE]';
|
|
if(index===1){name='report_task';args=report('progress','I checked the first excerpt.',['Compare the second excerpt']);}
|
|
if(index===2){name='report_task';args=report('complete','A and B both support delegation; B also requires verification.');}
|
|
}else if(scenario==='UX_RECOVERY'){
|
|
if(index===0){name='read_file';args={path:'missing-source.txt'};}
|
|
if(index===1){name='report_task';args=report('recovering','The file is missing. I will use the source excerpts you supplied.',['Compare the excerpts'],{attempts:['read_file returned not found; switching to supplied source text']});}
|
|
if(index===2){name='report_task';args=report('complete','Completed the comparison using the supplied sources.');}
|
|
}else{
|
|
if(index===0){name='request_takeover';args={intervention:'verification',site:'Local test fixture',reason:'Complete the human verification step.',why:'I will finish the original comparison.'};}
|
|
if(index===1){name='report_task';args=report('complete','Resumed and completed the original comparison.');}
|
|
}
|
|
const id=randomUUID();const choice=name?{role:'assistant',tool_calls:[{id,type:'function',function:{name,arguments:JSON.stringify(args)}}]}:{role:'assistant',content:text};
|
|
if(input.stream){
|
|
res.writeHead(200,{'content-type':'text/event-stream'});
|
|
const chunk=delta=>({id,object:'chat.completion.chunk',created:1,model:input.model,choices:[{index:0,delta,finish_reason:null}]});
|
|
res.write(`data: ${JSON.stringify(chunk(name?{role:'assistant',tool_calls:[{index:0,...choice.tool_calls[0]}]}:{role:'assistant',content:text}))}\n\n`);
|
|
res.write(`data: ${JSON.stringify({id,object:'chat.completion.chunk',created:1,model:input.model,choices:[{index:0,delta:{},finish_reason:name?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}})}\n\ndata: [DONE]\n\n`);res.end();
|
|
}else{res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({id,object:'chat.completion',created:1,model:input.model,choices:[{index:0,message:choice,finish_reason:name?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}));}
|
|
});
|
|
await new Promise(resolve=>fixture.listen(3119,'0.0.0.0',resolve));
|
|
async function api(path,body,method=body?'POST':'GET'){
|
|
const r=await fetch(base+path,{method,headers:{'content-type':'application/json',cookie},body:body?JSON.stringify(body):undefined});
|
|
if(r.headers.get('set-cookie'))cookie=r.headers.get('set-cookie').split(';')[0];
|
|
const raw=await r.text();assert.ok(r.ok,`${path}: ${r.status} ${raw}`);return raw?JSON.parse(raw):null;
|
|
}
|
|
async function until(fn,label){for(let i=0;i<150;i++){const found=await fn();if(found)return found;await new Promise(r=>setTimeout(r,150));}throw Error(`Timed out: ${label}`);}
|
|
try{
|
|
await api('/api/auth/register',{username:'ux'+randomUUID().replaceAll('-','').slice(0,12),password:'FixtureOnly123!'});
|
|
await api('/api/workspace/settings',{provider:'openai-compatible',modelId:'fixture-vision',baseUrl:'http://host.docker.internal:3119/v1',apiKey:'fixture-only'},'PATCH');
|
|
const bot=await api('/api/bots',{name:'UX fixture',computerMode:'team',memoryEnabled:false});
|
|
for(const scenario of ['UX_RESEARCH','UX_RECOVERY','UX_TAKEOVER']){
|
|
const session=await api(`/api/bots/${bot.id}/sessions`,{title:scenario});
|
|
await api(`/api/sessions/${session.id}/messages`,{text:`${scenario}: Compare these sources and deliver a concise report. Source A: assign the outcome once. Source B: assign the outcome once and verify the result. Use supplied excerpts if a file is missing.`,clientNonce:randomUUID()});
|
|
let run;
|
|
if(scenario==='UX_TAKEOVER'){
|
|
run=await until(async()=>{const [r]=await api(`/api/sessions/${session.id}/task`);return r?.status==='waiting_takeover'?r:null;},'takeover');
|
|
const messages=await api(`/api/sessions/${session.id}/messages`);
|
|
assert.equal(messages.flatMap(m=>m.blocks||[]).find(b=>b.kind==='login')?.intervention,'verification');
|
|
await api(`/api/computer/${bot.id}/release`,{});
|
|
}
|
|
const completed=await until(async()=>{const [r]=await api(`/api/sessions/${session.id}/task`);if(r?.status==='failed')throw Error(`run failed: ${scenario}`);return r?.status==='completed'?r:null;},scenario);
|
|
assert.equal(completed.report.state,'complete');assert.deepEqual(completed.report.remaining,[]);
|
|
if(run)assert.equal(completed.runId,run.runId,'human releases control; original run resumes');
|
|
const messages=await api(`/api/sessions/${session.id}/messages`);
|
|
assert.equal(messages.filter(m=>m.role==='user').length,1,'no repeated instruction');
|
|
assert.match(messages.at(-1).body,/comparison|delegation/);
|
|
if(scenario==='UX_RESEARCH')assert.equal(calls[scenario],3,'reject bare completion, then deliver without an extra model call');
|
|
if(scenario==='UX_RECOVERY')assert.equal(messages.filter(m=>m.blocks?.some(b=>b.kind==='progress')).length,1);
|
|
if(scenario==='UX_TAKEOVER')assert.match(histories[scenario][1],/CURRENT screen|current screen/);
|
|
console.log(`${scenario}: complete, one user assignment, ${run?'one verification step':'zero intervention'}`);
|
|
}
|
|
}finally{fixture.closeAllConnections();await new Promise(resolve=>fixture.close(resolve));}
|
|
});
|