import test from 'node:test'; import assert from 'node:assert/strict'; import {main} from '../src/cli.mjs'; import {broker,io,tmp} from './helpers.mjs'; const call=(s,cap,verb,args={})=>s.b.request(cap,{verb,args}); for(const follow of [false,true]) test(follow?'talk --follow continues after reply and prints a later unsolicited message':'follow prints an unsolicited message after plain talk returned',async t=>{ const s=broker(t), x=io(), controller=new AbortController(); let polls=0,sent; const transport=business=>{const inner=s.transport(business);return async(verb,args)=>{ if(verb==='message.send') {const result=await inner(verb,args);sent=result.id;return result;} if(verb==='message.receive'){ polls++; if(polls===1 && sent)call(s,s.coder,'message.send',{to:'human',body:'direct answer',in_reply_to:sent}); if(polls===2)call(s,s.coder,'message.send',{to:'human',body:'later unsolicited',class:'INFO'}); if(polls===3)controller.abort(); } return inner(verb,args); }}; const deps={system:{dataRoot:tmp(t)},transport,talkPollMs:5,signal:controller.signal}; if(!follow){await main(['talk','coder','question','--business','demo'],x,deps); assert.match(x.out.text,/direct answer/);} await main(follow?['talk','coder','question','--follow','--business','demo']:['follow','--business','demo'],x,deps); assert.equal(polls,3); assert.match(x.out.text,/later unsolicited/); assert.equal((x.out.text.match(/later unsolicited/g)||[]).length,1); assert.deepEqual(call(s,s.human,'message.receive'),[]); assert.equal(s.calls.filter(c=>c.verb==='message.send').length,1); assert.equal(s.calls.filter(c=>c.verb==='message.read').length,2); }); test('inbox lists all unread human messages under decisions, does not deliver or read them',async t=>{ const s=broker(t),x=io(),deps={system:{dataRoot:tmp(t)},transport:s.transport}; for(let i=0;i<205;i++)call(s,s.coder,'message.send',{to:'human',body:`unsolicited-${i}`,class:'INFO'}); const delivered=call(s,s.human,'message.receive'); call(s,s.human,'message.read',{id:delivered[0].id}); const before=s.store.all('SELECT * FROM deliveries'); await main(['inbox','--business','demo'],x,deps); assert.match(x.out.text,/inbox:/);assert.match(x.out.text,/messages: 204 unread/); assert.doesNotMatch(x.out.text,/unsolicited-0\n/); assert.match(x.out.text,/unsolicited-1\n/);assert.match(x.out.text,/unsolicited-204\n/); assert.deepEqual(s.store.all('SELECT * FROM deliveries'),before); const json=io(); await main(['inbox','--business','demo','--json'],json,deps); const value=JSON.parse(json.out.text);assert.deepEqual(value,[]);assert.equal(json.out.text,JSON.stringify(s.read("inbox"),null,2)+"\n"); }); test('follow refuses agent context and bad arguments',async t=>{ const s=broker(t),deps={system:{dataRoot:tmp(t)},transport:s.transport}; for(const argv of [['follow','extra'],['follow','--wait','1'],['talk','coder','hi','--follow','--wait','1']])await assert.rejects(main(argv,io(),deps),{exitCode:4}); await assert.rejects(main(['follow','--business','demo'],io({...io().env,CLAUDECODE:'1'}),deps),{exitCode:3}); }); test('follow Ctrl-C wakes the poll sleep and removes its handler; transport errors also clean up', async t => { const s = broker(t), x = io(), before = process.listenerCount('SIGINT'), cleanup = new AbortController(); let received; const ready = new Promise(resolve => { received = resolve; }); const deps = { system: { dataRoot: tmp(t) }, talkPollMs: 60000, signal: cleanup.signal, transport: () => async verb => { assert.equal(verb, 'message.receive'); received(); return []; } }; const running = main(['follow', '--business', 'demo'], x, deps); await ready; // Let the command install its sleep's abort listener before emitting Ctrl-C. await new Promise(resolve => setImmediate(resolve)); process.emit('SIGINT'); try { await Promise.race([running, new Promise((_, reject) => { const timer = setTimeout(() => reject(Error('Ctrl-C did not wake follow')), 500); timer.unref(); })]); } finally { cleanup.abort(); await running; } assert.equal(process.listenerCount('SIGINT'), before); await assert.rejects(main(['follow', '--business', 'demo'], io(), { ...deps, signal: undefined, transport: () => async () => { throw Error('transport failed'); }, }), /transport failed/); assert.equal(process.listenerCount('SIGINT'), before); }); test('plain talk leaves the process SIGINT handlers unchanged',async t=>{ const s=broker(t),before=process.listeners('SIGINT'),x=io();let sent; const deps={system:{dataRoot:tmp(t)},transport:business=>{const inner=s.transport(business);return async(verb,args)=>{ if(verb==='message.send'){const result=await inner(verb,args);sent=result.id;return result;} if(verb==='message.receive'){ assert.deepEqual(process.listeners('SIGINT'),before); call(s,s.coder,'message.send',{to:'human',body:'answer',in_reply_to:sent}); } return inner(verb,args); }}}; try {await main(['talk','coder','question','--business','demo'],x,deps);} finally {for(const handler of process.listeners('SIGINT'))if(!before.includes(handler))process.removeListener('SIGINT',handler);} assert.deepEqual(process.listeners('SIGINT'),before); });