Files
stack/packages/cli/tests/follow.test.mjs
T
jason.woltjeandClaude Opus 5.5 2b2a9e14ee bus, cli, webui: every message to the human, read state, Messages page (#1588)
Row 94, round 2. Rocko built it, Dewey approved it (comment 28104) on
manifest 53b01ea9 (16 paths, base e918a915). The bus keeps a messages
view with read state, where delivered but not read stays read:false. The
CLI gains messages and follow, and inbox leaves out messages already
read while inbox --json stays byte-compatible. The Console gets a
Messages page and an unread count in the nav. Mos gave the go under
decision 123 (public/ files). Sage gate on 645489e4 plus the patch,
12:52:14Z to 12:55:44Z: bus 115, cli 129, webui 104, all fail 0. Every
scripts/test-*.sh exited 0 (auth 15, conductor 17, config 24, discord
66, extension-package 18, foundation 44, queue 27, release 14, task 98).
Manifest 16/16 before and after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 07:58:36 -05:00

87 lines
5.1 KiB
JavaScript

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