// Row 194 (#1692, D7, T16): Settings' Agents section. Read-only mode shows every action unavailable // with the instance CLI named, and a run a fire's stop left alive is flagged (S6). In send mode a // launch posts once and follows its request until it closes; regenerate and delete take a second // click and name what stays; a terminate carries its reason; a refusal shows the bus's message and // nothing is retried. import test from 'node:test'; import assert from 'node:assert/strict'; import {browser} from './browser.mjs'; import {startServer} from '../src/serve.mjs'; import {close} from './fixture.mjs'; const hostile=''; const REV='a'.repeat(64), ID='0b5e8f1c-3a2d-4c6e-9f10-1234567890ab'; const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired:['old-coder'], instances:{coder:{definition:'coder',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:['run-0']},[hostile]:{definition:'coder',vars:{},launched:false,launcher:false,protected:false,kept:true,running:[],alive:[hostile]},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true,kept:false,endProtected:true,running:[]},bean:{definition:'companion',vars:{},launched:false,launcher:false,protected:false,kept:false,endProtected:true,running:[]}}, definitions:{assistant:{services:[],accepted:false},coder:{services:[],accepted:true},reviewer:{services:[],accepted:true}}}; test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{ const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>view}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser(); try{ // POSTs answer from `replies`, request reads from `polls`; both record what was asked. await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.polls=[];window.reads=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'&&/^\\/api\\/(agent|veto|instances)/.test(url)){posts.push([url,JSON.parse(options.body)]);const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}if(String(url).startsWith('/api/agent/request')){reads.push(url);const [status,body]=polls.shift()??[200,{request:'${ID}',action:'launch',instance:'coder',state:'open',done:null,cancelled:null}];return new Response(JSON.stringify(body),{status});}return original(url,options);};`}); const port=web.address().port; const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); const receipt=()=>b.evaluate('document.querySelector("#agents-receipt").textContent'); const click=sel=>b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});if(e.closest('.agent-menu')?.hidden)e.closest('.agent-menu').previousElementSibling.click();e.click();return true;})()`); // Read-only: the table, the unavailable line, every action disabled, nothing injected. await b.navigate(`http://127.0.0.1:${port}/#/settings/agents`); await wait('!!document.querySelector("#agents-table")'); assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,5).map(c=>{const copy=c.cloneNode(true);copy.querySelector('.agent-mobile-label')?.remove();return copy.textContent}))"),[ ['coder','coder','Yes','No','run-1 still running after fire: run-0'],[hostile,'coder','No','Yes',`None still running after fire: ${hostile}`],['assistant','assistant','No','No','None'],['bean','companion','No','No','None'], ]); assert.equal(await b.evaluate('window.injected===true'),false); // D7 (S6): a run a fire's stop left alive is flagged beside the running runs, as text. assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table .agent-alive code')].map(c=>{const copy=c.cloneNode(true);copy.querySelector('.agent-mobile-label')?.remove();return copy.textContent})"),['run-0',hostile]); assert.match(await b.evaluate('document.querySelector(".settings-readonly").textContent'),/Read only/); assert.equal(await b.evaluate(`document.querySelectorAll('#agents-table .agent-start-hint').length`),2); assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action],#agents-section .agent-more").length'),0); assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Retired names, never reused: old-coder\./); // Send mode: a launch posts once, shows the request and follows it to its close. await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings/agents`); await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="coder"]')?.disabled===false`); assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'),false); assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-dialog="delete"][data-agent-name="assistant"]')`),false); assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-action="launch"][data-agent-name="assistant"]:not(:disabled)')`),false); assert.match(await b.evaluate(`document.querySelector('[data-agent-name="assistant"]').closest("tr").textContent`),/Starts when you message it/); assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-dialog="delete"][data-agent-name="bean"]')`),false); assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile); assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="hire"]').dataset.agentName`),'reviewer'); await b.evaluate(`replies.push([200,{request:'${ID}',at:'2026-10-20T00:00:00Z'}]);polls.push([200,{request:'${ID}',action:'launch',instance:'coder',state:'taken',done:null,cancelled:null}],[200,{request:'${ID}',action:'launch',instance:'coder',state:'closed',done:{run:'run-2',transaction:null,refusal:null},cancelled:null}]);true`); await click('[data-agent-action="launch"][data-agent-name="coder"]'); await wait('!!document.querySelector("#agents-pending")'); assert.match(await b.evaluate('document.querySelector("#agents-pending").textContent'),new RegExp(`Request ${ID} to launch coder`)); assert.equal(await b.evaluate('document.querySelector(\'[data-agent-action="launch"][data-agent-name="coder"]\').disabled'),true,'one request at a time'); await wait('document.querySelector("#agents-receipt")?.textContent==="Launched coder (run run-2)."'); assert.deepEqual(await b.evaluate('posts'),[['/api/agent/launch',{instance:'coder'}]]); assert.deepEqual(await b.evaluate('reads'),[`/api/agent/request?id=${ID}`,`/api/agent/request?id=${ID}`]); assert.equal(await b.evaluate('!!document.querySelector("#agents-pending")'),false); // Regenerate and delete: the first click only asks; Cancel asks nothing; the confirm posts. await click('[data-agent-dialog="regenerate"][data-agent-name="coder"]'); await wait('document.querySelector("#agent-dialog")?.open'); assert.match(await b.evaluate('document.querySelector("#agent-dialog").textContent'),/Regenerate coder\?/); await click('#agent-dialog-cancel'); await wait('!document.querySelector("#agent-dialog").open'); await click('[data-agent-dialog="delete"][data-agent-name="coder"]'); await wait('document.querySelector("#agent-dialog")?.open'); assert.match(await b.evaluate('document.querySelector("#agent-dialog").textContent'),/Its evidence and its workspace under \.retired stay\./); assert.equal(await b.evaluate('posts.length'),1,'asking posts nothing'); await b.evaluate(`replies.push([200,{request:'${ID}',at:'x'}]);polls.push([200,{request:'${ID}',action:'delete',instance:'coder',state:'closed',done:{run:null,transaction:'t-9',refusal:null},cancelled:null}]);true`); await click('#agent-dialog-confirm'); await wait('document.querySelector("#agents-receipt")?.textContent==="Deleted coder."'); assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/instances',{revision:REV,instance:'coder',op:'delete'}]); // sage-1610 (b): a delete that closes with a tracker bot names it for removal in Vikunja; a bot // name outside the pattern is not shown. for (const [bot,text] of [['bot-acme-coder','Deleted coder. Remove the Vikunja bot user bot-acme-coder and any token it holds by hand in Vikunja.'],[hostile,'Deleted coder.']]) { await b.evaluate(`replies.push([200,{request:'${ID}',at:'x'}]);polls.push([200,{request:'${ID}',action:'delete',instance:'coder',state:'closed',done:{run:null,transaction:'t-9',refusal:null,bot:${JSON.stringify(bot)}},cancelled:null}]);true`); await click('[data-agent-dialog="delete"][data-agent-name="coder"]'); await wait('document.querySelector("#agent-dialog")?.open'); await click('#agent-dialog-confirm'); await wait(`document.querySelector("#agents-receipt")?.textContent===${JSON.stringify(text)}`); } assert.equal(await b.evaluate('window.injected===true'),false); // Create names whether the bus must restart, in the words bus.js uses for the same answer (M63). const typeCreate=(field,value)=>b.evaluate(`(()=>{const e=document.querySelector('#instance-add-form [data-instance-field="${field}"]');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`); for (const [restart,text] of [[true,'Added rev-2. Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.'],[false,'Added rev-2. No restart: the launcher reads the change at its next launch check.']]) { await typeCreate('instance','rev-2'); await typeCreate('definition','reviewer'); await b.evaluate(`replies.push([200,{...${JSON.stringify(view)},transaction:'t-3',restart:${restart}}]);true`); await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true'); await wait(`document.querySelector("#instances-receipt")?.textContent===${JSON.stringify(text)}`); assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/instances',{revision:REV,instance:'rev-2',op:'add',definition:'reviewer'}]); } // Terminate carries the typed reason; a closed refusal shows its reason. await b.evaluate(`replies.push([200,{request:'${ID}',runs:['run-1'],cancelled:[],taken:[]}]);polls.push([200,{request:'${ID}',action:'end',instance:'coder',state:'closed',done:{run:null,transaction:null,refusal:'not-held'},cancelled:null,reason:'the run no longer holds the instance'}]);true`); await click('[data-agent-dialog="end"][data-agent-name="coder"]'); await b.evaluate('document.querySelector("#agent-dialog-reason").value="done for today"'); await click('#agent-dialog-confirm'); await wait('document.querySelector("#agents-receipt")?.textContent==="coder: not-held: the run no longer holds the instance."'); assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/agent/terminate',{instance:'coder',reason:'done for today'}]); // A refusal shows the bus's message and follows nothing; Keep posts the read's revision. await b.evaluate(`replies.push([400,{error:'no-route',message:'no-route: set a route in Routing first. Nothing changed.',sent:false}]);true`); const before=await b.evaluate('reads.length'); await click('[data-agent-action="launch"][data-agent-name="coder"]'); await wait('document.querySelector("#agents-receipt")?.textContent.startsWith("no-route:")'); assert.equal(await b.evaluate('reads.length'),before); await b.evaluate(`replies.push([200,{...${JSON.stringify(view)},transaction:'t-2',restart:false}]);true`); await click('[data-agent-action="keep"][data-agent-name="coder"]'); await wait('document.querySelector("#agents-receipt")?.textContent==="Kept coder. No restart: the launcher reads the change at its next launch check."'); assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/veto',{revision:REV,op:'keep',name:'coder'}]); // An unknown outcome says so and is not retried. await b.evaluate(`replies.push([503,{error:'outcome-unknown',sent:'unknown'}]);true`); const sent=await b.evaluate('posts.length'); await click('[data-agent-action="nohire"][data-agent-name="coder"]'); await wait('document.querySelector("#agents-receipt")?.textContent.startsWith("Outcome unknown")'); assert.equal(await b.evaluate('posts.length'),sent+1); }finally{await b.close();await close(web);} }); // Row 256 (#1761, T7, T8): the human's listing carries endProtected false for a creative instance // (the server's delete check for that reader), so the human's page offers Delete for it. The tester's // page offers no Delete even on that same listing; the tester's own listing marks bean endProtected // as well (bus creative.test T6). test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{ const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}}; const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser(); try{ await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`}); const port=web.address().port; const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); const del=name=>`document.querySelector('[data-agent-dialog="delete"][data-agent-name="${name}"]')`; // T7: the human's page. await b.navigate(`http://127.0.0.1:${port}/?human=1#/settings/agents`); await wait(`!!document.querySelector('[aria-label="More actions for bean"]')`); assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]:not(:disabled)')`),false); assert.equal(await b.evaluate(`${del('bean')}?.disabled`),false); assert.equal(await b.evaluate(`!!${del('assistant')}`),false); assert.equal(await b.evaluate(`!!${del('coder')}`),true); // T8: the tester's page, on the same listing. await b.navigate(`http://127.0.0.1:${port}/?tester=1#/settings/agents`); await wait(`!!document.querySelector('#tester-banner')&&!!document.querySelector('[aria-label="More actions for bean"]')`); assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]:not(:disabled)')`),false); assert.equal(await b.evaluate(`document.querySelectorAll('[data-agent-dialog="delete"]').length`),0); }finally{await b.close();await close(web);} }); test('a refused launch shows its code and skill activation hint', {timeout:30000}, async () => { const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage')},instances:async()=>view}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser(); try { await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/agent/launch')return new Response(JSON.stringify({request:'${ID}'}));if(String(url).startsWith('/api/agent/request'))return new Response(JSON.stringify({request:'${ID}',action:'launch',instance:'coder',state:'closed',done:{refusal:'skill-not-enabled'},reason:"A skill this role needs isn't turned on: scripts/skill.sh activate "}));return original(url,options)};`}); await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/agents`); const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error('wait failed: '+${JSON.stringify(expr)})})()`); await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="coder"]')`); await b.evaluate(`document.querySelector('[data-agent-action="launch"][data-agent-name="coder"]').click()`); await wait(`document.querySelector('#agents-receipt')?.textContent.includes('scripts/skill.sh activate')`); const receipt=await b.evaluate(`document.querySelector('#agents-receipt').textContent`); assert.match(receipt,/skill-not-enabled/); assert.match(receipt,/scripts\/skill\.sh activate/); } finally {await b.close();await close(web)} });