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]>
This commit is contained in:
jason.woltjeandClaude Opus 5.5 committed 2026-10-16 15:46:40 -05:00
1 parent 1ad48035cc
commit 0f7bca083f
7 files changed
+204 -23

No files matched your search

+5 -1
View File
@@ -231,6 +231,7 @@ config problem · `4` usage or a required file missing. Details:
```bash
scripts/mosaic # no argument: the full-screen app
scripts/mosaic inbox | tasks | agents [--business <id>] [--json]
scripts/mosaic inbox --read <id>... | --read-all [--business <id>]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm] | stop | status [--json]
@@ -265,7 +266,10 @@ crashed host left as `host-lost` and refuses with exit 3 on a
`sessions.json` it can't read. `talk` sends a REQUEST and prints only the
reply to it, marking only that read (polls every 2 s, `--wait` default 900;
`0` only sends); other messages stay unread for `mosaic inbox`, and
`--follow` and `follow` print and read everything. When no run holds the
`--follow` and `follow` print and read everything. `inbox --read <id>...`
marks unread messages read by the ids `inbox` prints, and `--read-all` marks
every one it lists (row 150); the cli README, "Human commands", says how
refusals print. When no run holds the
role, a line after `sent` says whether `launch.by` was told, or that the role
isn't in the launch block and `launch.by` can't launch it (row 152); the cli
README, "Human commands", lists the lines. `stop` sends SIGTERM to
+4 -2
View File
@@ -1757,8 +1757,10 @@ export class Broker {
}
// Row 81 (#1575): the replies to a message, for the Console's web-send receipt. Human and
// reader sessions only, like decisions. It writes no delivery or read row, so a reply it
// lists still reaches the next `mosaic talk`. An id naming no message in the business
// refuses message-not-found, as message.send does for in_reply_to; trail would return [].
// lists stays unread in `mosaic inbox` until the human marks it read (`mosaic inbox
// --read`, row 150); since row 142 `mosaic talk` prints only replies to its own message.
// An id naming no message in the business refuses message-not-found, as message.send does
// for in_reply_to; trail would return [].
// Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a
// project from the business file; the rename to repos is a P19 follow-up.
case 'project.create': {
+20 -3
View File
@@ -25,6 +25,7 @@ node --test 'packages/cli/tests/*.test.mjs'
```
mosaic inbox [--business <id>] [--json]
mosaic inbox --read <id>... | --read-all [--business <id>]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
@@ -52,6 +53,21 @@ mosaic launches list [--json]
the action, its target, and the one choice that authorizes it. Text output
then lists all unread messages to the human, newest first, without marking
any delivered or read. `--json` keeps its existing decision-row array.
- `inbox --read <id>...` (row 150, #1644) marks unread messages read with
the broker's `message.read`. Each id is the 8-character one `inbox`
prints, or any longer prefix of exactly one unread message's id. A
shorter one refuses with `too-short`. A prefix that matches two or more
refuses with `ambiguous` and names them; one that matches none refuses
with `no-unread-match`, unless it is a full message id, which goes to the
broker as given. `--read-all` marks every message
`inbox` lists, oldest first. Each id prints `marked <id>` or `not marked
<id>: <code>` (`--read-all` prints only the refusals), then `marked N of
M`. Every id is tried; if any was refused the command exits 2 after the
rest. A message the human never received has to be received first, so
the command calls `message.receive` until it has that message. Every
message that call receives drops out of a later `follow`, like the ones
plain `talk` skips, and stays unread in `inbox` until marked. Neither flag
takes `--json` or `--tester` (exit 4).
- `decide` takes the full id, or a unique prefix of at least 8 characters,
from the inbox. It prints the decision and whether the choice authorizes
or declines, then asks `[y/N]` on a terminal. Without a terminal it needs
@@ -84,7 +100,8 @@ mosaic launches list [--json]
printing and marking every message read until Ctrl-C, including messages
with no `in_reply_to`. `follow` runs that same loop without sending. `--wait`
and `--follow` together refuse. These commands consume the same human
inbox; run one receiving CLI at a time. Console only reads history.
inbox; run one receiving CLI at a time. Console only reads history, so a
reply to a Console send stays unread in `inbox` until it is marked read.
- `launches off` and `on` are the broker's `launch.revoke` and
`launch.restore`. While off, every `role.launch` refuses with
`launch-revoked`; running sessions keep running. Stop them with `stop`.
@@ -105,8 +122,8 @@ mosaic launches list [--json]
only from the tree the bus runs from ("One pinned copy" below).
`talk --tester` text must start with
`[mos test]`, else exit 2 before any call. The role's reply comes back to
the tester, not to the human's inbox. `decide`, `stop` and `launches`
take no `--tester` (exit 4).
the tester, not to the human's inbox. `decide`, `stop`, `launches` and
`inbox --read` or `--read-all` take no `--tester` (exit 4).
## The app
+73 -9
View File
@@ -20,7 +20,7 @@ import { CliError } from "./errors.mjs";
import { bootConfig, loadSystem, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../business/src/index.mjs";
import { humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
import { createLauncher } from "./launcher.mjs";
@@ -33,6 +33,7 @@ export const USAGE = `usage:
${PLUGINS_USAGE}
${PROJECT_USAGE}
mosaic inbox [--business <id>] [--json] [--tester]
mosaic inbox --read <id>... | --read-all [--business <id>]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json] [--tester]
mosaic agents [--business <id>] [--json] [--tester]
@@ -175,11 +176,80 @@ function callFor(parsed, io, deps) {
return deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
}
// The human's unread messages, newest first, from every page of `messages`.
async function unreadMessages(call) {
const messages = [];
let after;
do {
const page = await call("messages", { limit: 200, ...(after ? { after } : {}) });
messages.push(...page.messages.filter(m => !m.read));
after = page.next;
} while (after);
return messages;
}
const MESSAGE_ID_LENGTH = 36;
// Row 150 (#1644): `mosaic inbox --read <id>...` and `--read-all` mark unread messages read
// through message.read. Every id is tried; a refusal of one is printed with its code and the
// command exits 2 at the end. A message never received refuses not-recipient, so it is received
// first, which stops a later `mosaic follow` printing it. See agents/darkwing/work/queue-150/BUILD.md.
async function markRead(parsed, call, io) {
const unread = await unreadMessages(call);
const all = parsed.flags.has("--read-all");
const targets = all ? unread.slice().reverse().map(m => ({ arg: m.id, id: m.id })) : parsed.positional.map(arg => {
// At least the 8 characters inbox prints, as decide asks of a decision reference.
if (arg.length < 8) return { arg, refused: "too-short, use at least 8 characters of the id" };
const matches = unread.filter(m => String(m.id).startsWith(arg));
if (matches.length === 1) return { arg, id: matches[0].id };
if (matches.length > 1) return { arg, refused: `ambiguous, matches ${matches.map(m => m.id).join(", ")}` };
return arg.length === MESSAGE_ID_LENGTH ? { arg, id: arg } : { arg, refused: "no-unread-match" };
});
const perMessage = e => typeof e?.code === "string" && /^[a-z-]{1,64}$/.test(e.code) && busExit(e.code) === 2;
const read = async id => {
try {
await call("message.read", { id });
} catch (e) {
if (e.code !== "not-recipient") throw e;
// Undelivered: receive until a batch holds it or none is left, then read once more.
let batch;
do batch = await call("message.receive"); while (batch.length && !batch.some(m => m.id === id));
await call("message.read", { id });
}
};
let marked = 0;
for (const t of targets) {
if (t.refused === undefined) {
try {
await read(t.id);
marked++;
if (!all) io.stdout.write(`marked ${shortId(t.id)}\n`);
continue;
} catch (e) {
if (!perMessage(e)) throw e;
t.refused = e.code;
}
}
io.stdout.write(`not marked ${t.arg}: ${t.refused}\n`);
}
io.stdout.write(`marked ${marked} of ${targets.length}\n`);
if (marked < targets.length) throw new CliError(`${targets.length - marked} of ${targets.length} not marked`, 2);
}
async function human(verb, rest, io, deps) {
const spec = verb === "decide" ? { flags: ["--yes"], values: ["--business", "--note"] } : { flags: ["--json", "--tester"], values: ["--business"] };
const spec = verb === "decide" ? { flags: ["--yes"], values: ["--business", "--note"] }
: { flags: ["--json", "--tester", ...(verb === "inbox" ? ["--read", "--read-all"] : [])], values: ["--business"] };
const parsed = parse(rest, spec);
const marking = parsed.flags.has("--read") || parsed.flags.has("--read-all");
if (parsed.flags.has("--read") && parsed.flags.has("--read-all")) throw usage("--read and --read-all cannot be combined");
if (marking && parsed.flags.has("--json")) throw usage("--read and --read-all print no JSON");
// The tester's message.read reads the tester's own messages, not the human's that inbox lists.
if (marking && parsed.flags.has("--tester")) throw usage("--read and --read-all take no --tester");
if (parsed.flags.has("--read") && parsed.positional.length === 0) throw usage("--read takes one or more message ids");
if (parsed.flags.has("--read-all") && parsed.positional.length !== 0) throw usage("--read-all takes no ids");
const call = callFor(parsed, io, deps);
if (verb === "decide") return decide(parsed, call, io);
if (marking) return markRead(parsed, call, io);
const json = parsed.flags.has("--json");
const print = (value, text) => io.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : text(value));
if (verb === "trail") {
@@ -191,13 +261,7 @@ async function human(verb, rest, io, deps) {
if (verb === "inbox") {
const decisions = await call("inbox");
if (json) return print(decisions, formatInbox);
const messages = [];
let after;
do {
const page = await call("messages", { limit: 200, ...(after ? { after } : {}) });
messages.push(...page.messages.filter(m => !m.read));
after = page.next;
} while (after);
const messages = await unreadMessages(call);
return print({ decisions, messages }, value => formatInbox(value.decisions) +
`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(""));
}
+92
View File
@@ -0,0 +1,92 @@
// 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,[]);
});
+5 -3
View File
@@ -258,11 +258,13 @@
// 10 s refresh redraws the form without losing it. Nothing resends: an unknown outcome stays
// unknown and the draft stays put.
// Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each
// render. That read marks nothing, so every reply still reaches the next `mosaic talk`.
// render. That read marks nothing. Row 150 (#1644): since row 142, `mosaic talk` prints only the
// reply to its own message, so a reply to a Console send stays unread in `mosaic inbox` until
// the human marks it read with `mosaic inbox --read`.
let canSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
function repliesHtml() {
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic talk</code> in a terminal prints them.</p>';
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
if (!replies.length) return '<p class="small muted">No reply yet. One shows here when the role answers, as the page refreshes.</p>';
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
}
@@ -277,7 +279,7 @@
? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (<code>${txt(r.code)}</code>)`}. The draft is kept.</p>`
: `<p class="receipt failed">Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.</p>`;
return `<section class="panel s1-send" id="send-section" aria-labelledby="b-send"><h2 id="b-send">Message a role</h2>
<p class="small muted">Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. Replies to it show under the receipt. The Console only shows them and never marks one delivered or read, so the next <code>mosaic talk</code> in a terminal still prints each one.</p>
<p class="small muted">Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. Replies to it show under the receipt. The Console never marks one read, so each stays unread in <code>mosaic inbox</code> in a terminal until you mark it read with <code>mosaic inbox --read</code>.</p>
${roles === null ? '<p class="small warn">The held roles could not be read, so there is nothing to pick. Read again to retry.</p>' : !roles.length ? '<p class="small muted">No role is held right now, so there is nothing to pick.</p>' : ''}
<form id="send-form" class="reply-form" novalidate><label for="send-to">To</label><select id="send-to" data-field="to"><option value="">Choose a held role</option>${options}</select>
<label for="send-body">Message</label><textarea id="send-body" data-field="body" rows="4" placeholder="What should the role do?">${esc(draft.body)}</textarea>
+5 -5
View File
@@ -102,7 +102,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
await b.navigate(`${base}#/inbox`);
await wait('document.querySelector("#send-form")');
assert.equal(await text('#send-section h2'), 'Message a role');
assert.match(await text('#send-section'), /Replies to it show under the receipt\. The Console only shows them and never marks one delivered or read, so the next mosaic talk in a terminal still prints each one\./);
assert.match(await text('#send-section'), /Replies to it show under the receipt\. The Console never marks one read, so each stays unread in mosaic inbox in a terminal until you mark it read with mosaic inbox --read\./);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent])'), [['', 'Choose a held role'], ['coder', 'coder'], ['pm', 'pm']]);
// Nothing to send is refused in the page, without a request.
@@ -219,8 +219,8 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
});
// Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each
// render. The read writes no delivery or read row, so the human's next message.receive
// (mosaic talk) still gets every reply the page showed.
// render. The read writes no delivery or read row, so every reply the page showed stays unread
// for mosaic inbox, and the human's next message.receive still gets it (row 150, #1644).
test('a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read', { timeout: 120000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-replies-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
@@ -295,7 +295,7 @@ test('a sent receipt lists its replies as the page refreshes, oldest first and c
await wait('document.querySelectorAll("#send-section .s1-replies li").length === 3', 300);
assert.deepEqual((await items()).map(i => i[1]).slice(1), ['Second look.', 'Third.']);
// Showing them wrote nothing: mosaic talk's message.receive still gets each one, and once
// Showing them wrote nothing: the human's message.receive still gets each one, and once
// the human has received and read them they are still listed.
assert.deepEqual(deliveries(), before, 'no delivered or read row');
const got = b0.request(human, { verb: 'message.receive' });
@@ -318,7 +318,7 @@ test('a sent receipt lists its replies as the page refreshes, oldest first and c
await send('00000000-0000-4000-8000-000000000000', 'Lost?');
assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
await refresh();
assert.match(await text('#send-section'), /The replies to it could not be read\. Read again to retry; mosaic talk in a terminal prints them\./);
assert.match(await text('#send-section'), /The replies to it could not be read\. Read again to retry; mosaic inbox in a terminal lists them\./);
assert.deepEqual(await items(), []);
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .s1-stale, #bus-view .banner")'), false);
assert.match(await text('#send-section .receipt'), /, message 00000000\. Its trail\.$/);