/** 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));} });