Files
stack/packages/cli/tests/mark-read.test.mjs
jason.woltjeandClaude Opus 5.5 0f7bca083f cli, webui: the human marks skipped messages read, and the Console says what talk prints (row 150, #1644)
Row 150 round 2, Darkwing's candidate (agents/darkwing/work/queue-150/r2/,
manifest 7556dda0, 7 paths), a rebase-only round onto 63c82f1c4 after
row 139 changed broker.mjs. Filbert approved round 1 (comment 28800) and
the rebase (comment 28806): only broker.mjs's index line and hunk offset
moved. Darkwing's r2 round ran 6 files, ROUND_PASSED.

`mosaic inbox --read <id>...` and `--read-all` mark the human's unread
messages read. Each argument is tried; refusals print their code and the
command exits 2. The Console's send section now names `mosaic inbox` and
`mosaic inbox --read`. broker.mjs changes a comment only.

The public/ diff is sha256 096d922c, unchanged from round 1 and covered
by Mos's decision 123 go.

Sage's landing gate ran on 4ebef2284 plus the candidate: 25 steps,
all rc 0 (15 packages, 10 script suites), 20:41:55Z to 20:46:24Z,
network sage-r150g_default removed. Cherry-picked onto 1ad48035c, which
added only a docs/SESSIONS.md line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 15:46:40 -05:00

93 lines
6.0 KiB
JavaScript

// Row 150 (#1644): mosaic inbox --read <id>... and --read-all mark the human's unread messages
// read through message.read, receiving an undelivered one first.
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});
const unread=s=>s.store.all("SELECT m.id FROM messages m WHERE to_role='human' AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read') ORDER BY seq").map(r=>r.id);
const send=(s,body)=>call(s,s.coder,'message.send',{to:'human',body,class:'INFO'});
test('inbox --read marks one message by the id inbox prints, delivered or not, and leaves the rest unread',async t=>{
const s=broker(t),deps={system:{dataRoot:tmp(t)},transport:s.transport};
const skipped=send(s,'skipped by talk'), other=send(s,'stays unread');
call(s,s.human,'message.receive'); // what plain talk leaves: delivered, unread
const fresh=send(s,'never received'); // undelivered
const later=send(s,'arrives after');
const x=io();
await main(['inbox','--read',skipped.id.slice(0,8),'--business','demo'],x,deps);
assert.equal(x.out.text,`marked ${skipped.id.slice(0,8)}\nmarked 1 of 1\n`);
assert.deepEqual(unread(s),[other.id,fresh.id,later.id]);
assert.ok(!s.calls.some(c=>c.verb==='message.receive'),'a delivered message is read without receiving');
const y=io();
await main(['inbox','--read',fresh.id,'--business','demo'],y,deps);
assert.equal(y.out.text,`marked ${fresh.id.slice(0,8)}\nmarked 1 of 1\n`);
assert.deepEqual(unread(s),[other.id,later.id]);
// The receive that made it readable delivered `later` too: inbox still lists it, follow won't.
assert.deepEqual(call(s,s.human,'message.receive'),[]);
const z=io(); await main(['inbox','--business','demo'],z,deps);
assert.match(z.out.text,/messages: 2 unread/); assert.match(z.out.text,/arrives after/);
});
test('inbox --read refuses an id that is not the human\'s, prints its code, still marks the rest and exits 2',async t=>{
const s=broker(t),deps={system:{dataRoot:tmp(t)},transport:s.transport};
const mine=call(s,s.human,'message.send',{to:'coder',body:'to the coder'});
const a=send(s,'first'), b=send(s,'second');
const x=io();
await assert.rejects(main(['inbox','--read',a.id.slice(0,8),mine.id,'00000000',b.id.slice(0,12),'--business','demo'],x,deps),
e=>e.exitCode===2&&e.message==='2 of 4 not marked');
assert.equal(x.out.text,[`marked ${a.id.slice(0,8)}`,`not marked ${mine.id}: message-not-found`,'not marked 00000000: no-unread-match',`marked ${b.id.slice(0,8)}`,'marked 2 of 4',''].join('\n'));
assert.deepEqual(unread(s),[]);
assert.deepEqual(s.calls.filter(c=>c.verb==='message.read').map(c=>c.args.id).filter(id=>id===mine.id),[mine.id],'a full id goes to the broker as given');
});
test('inbox --read refuses an ambiguous or short prefix, names every match, reads nothing for them and marks the rest',async t=>{
const messages=[{id:'aaaa1111-0000-4000-8000-000000000001',read:false},{id:'aaaa1111-0000-4000-8000-000000000002',read:false},{id:'bbbb3333-0000-4000-8000-000000000003',read:false}];
const calls=[];
const transport=()=>async(verb,args={})=>{
calls.push({verb,args});
if(verb==='messages')return {messages,total:3,unread:3,next:null};
if(verb==='message.read')return {read:true};
throw new Error(`unexpected ${verb}`);
};
const x=io();
await assert.rejects(main(['inbox','--read','aaaa1111','bbbb3333','aaaa','','--business','demo'],x,{system:{dataRoot:tmp(t)},transport}),{exitCode:2});
assert.equal(x.out.text,[`not marked aaaa1111: ambiguous, matches ${messages[0].id}, ${messages[1].id}`,'marked bbbb3333','not marked aaaa: too-short, use at least 8 characters of the id','not marked : too-short, use at least 8 characters of the id','marked 1 of 4',''].join('\n'));
assert.deepEqual(calls.filter(c=>c.verb==='message.read').map(c=>c.args.id),[messages[2].id]);
});
test('inbox --read-all marks every message the inbox walk lists, delivered or not, and inbox then shows 0 unread',async t=>{
const s=broker(t),deps={system:{dataRoot:tmp(t)},transport:s.transport};
for(let i=0;i<120;i++)send(s,`early-${i}`);
call(s,s.human,'message.receive'); // 100 delivered, 20 not
for(let i=0;i<110;i++)send(s,`late-${i}`);
const x=io();
await main(['inbox','--read-all','--business','demo'],x,deps);
assert.equal(x.out.text,'marked 230 of 230\n');
assert.deepEqual(unread(s),[]);
assert.equal(s.calls.filter(c=>c.verb==='message.read').length,232,'one retry at each receive batch');
assert.equal(s.calls.filter(c=>c.verb==='message.receive').length,2);
const y=io(); await main(['inbox','--business','demo'],y,deps);
assert.match(y.out.text,/messages: 0 unread\n$/);
const z=io(); await main(['inbox','--read-all','--business','demo'],z,deps);
assert.equal(z.out.text,'marked 0 of 0\n');
});
test('inbox --read stops on an error that is not about one message',async t=>{
const transport=()=>async verb=>{
if(verb==='messages')return {messages:[{id:'cccc4444-0000-4000-8000-000000000004',read:false}],total:1,unread:1,next:null};
const e=new Error('outcome-unknown');e.code='outcome-unknown';e.exitCode=1;throw e;
};
const x=io();
await assert.rejects(main(['inbox','--read','cccc4444','--business','demo'],x,{system:{dataRoot:tmp(t)},transport}),{exitCode:1});
assert.equal(x.out.text,'');
});
test('inbox --read and --read-all refuse bad combinations; other verbs refuse them',async t=>{
const s=broker(t),deps={system:{dataRoot:tmp(t)},transport:s.transport};
for(const argv of [['inbox','--read'],['inbox','--read','--read-all'],['inbox','--read-all','abcd'],['inbox','--read','abcd','--json'],['inbox','--read-all','--json'],['inbox','--read','abcd','--tester'],['inbox','--read-all','--tester'],['tasks','--read','abcd'],['agents','--read-all']])
await assert.rejects(main([...argv,'--business','demo'],io(),deps),{exitCode:4},argv.join(' '));
assert.deepEqual(s.calls,[]);
});