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]>
This commit is contained in:
2026-10-13 07:58:36 -05:00
co-authored by Claude Opus 5.5
parent 645489e4cc
commit 2b2a9e14ee
16 changed files with 441 additions and 42 deletions
+14 -1
View File
@@ -229,7 +229,7 @@ must not invoke the human CLI or work around its proof.
Human resolutions and launch toggles append `human.input`. Agent capabilities
and read-only WebUI capabilities cannot reach these operations. Reader
capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials/replies for one
capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials/replies/messages for one
business; they do not confer mutation or resolution authority.
`decisions` (row 57, #1546) is for human and reader sessions only; a role
@@ -279,6 +279,18 @@ still reaches the human's next `message.receive` (`mosaic talk`). It is a
separate verb rather than a `trail` field so `trail`'s shape and its
empty-list answer for unknown subjects stay as they are.
`messages` (row 94, #1588) is a read for human and reader sessions only.
It takes `{limit?, after?}`; `limit` defaults to 50 and must be an integer
from 1 to 200. It returns `{messages, total, unread, next}`. Messages are
only those addressed to `human` in this capability's business, newest
first by immutable sequence. Each message adds `read`, true only when a
read receipt exists. Counts cover the entire feed. `next` is the last
returned message id if another page exists, otherwise null; pass it as
`after` to read older messages. A cursor that is not in this human feed
refuses `message-not-found`; malformed arguments refuse `invalid-request`.
Roles get `human-required`. A successful read records nothing, so Console
history reads never prevent a later `message.receive` from delivering it.
## Shared reads and adapter boundary
```js
@@ -289,6 +301,7 @@ await read.inbox(); await read.tasks(); await read.agents();
await read.trail('vikunja:3/41');
await read.decisions('roles'); await read.decisions('recent');
await read.replies(messageId);
await read.messages({limit: 50});
```
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
+22 -1
View File
@@ -555,7 +555,7 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies'].includes(request.verb))
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -978,6 +978,27 @@ export class Broker {
};
});
}
// Row 94: the human's message history. Sequence-based cursors remain stable
// as new messages arrive. Successful reads write no delivery or read receipt.
case 'messages': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['limit', 'after']);
if (('limit' in a && !(Number.isSafeInteger(a.limit) && a.limit >= 1 && a.limit <= 200)) ||
('after' in a && !id(a.after))) fail('invalid-request');
let cursor = null;
if ('after' in a) {
cursor = this.#store.get("SELECT seq FROM messages WHERE business=? AND to_role='human' AND id=?", s.business, a.after);
if (!cursor) fail('message-not-found');
}
const counts = this.#store.get(
"SELECT count(*) AS total, coalesce(sum(NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read')),0) AS unread FROM messages m WHERE business=? AND to_role='human'", s.business);
const limit = a.limit ?? 50;
const rows = this.#store.all(
"SELECT m.*, EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read') AS read FROM messages m WHERE business=? AND to_role='human' AND (? IS NULL OR m.seq<?) ORDER BY m.seq DESC LIMIT ?",
s.business, cursor?.seq ?? null, cursor?.seq ?? null, limit + 1);
const messages = rows.slice(0, limit).map(m => ({ ...m, read: !!m.read }));
return { messages, ...counts, next: rows.length > limit ? messages.at(-1).id : null };
}
// 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
+1
View File
@@ -8,5 +8,6 @@ export function views(client) {
decisions: (list) => client.call('decisions', { list }),
credentials: () => client.call('credentials'),
replies: (id) => client.call('replies', { id }),
messages: (args = {}) => client.call('messages', args),
});
}
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Broker } from '../src/broker.mjs';
import { BUSINESSES, broker } from '../../cli/tests/helpers.mjs';
import { views } from '../src/views.mjs';
const call = (s, cap, verb, args = {}) => s.b.request(cap, { verb, args });
const send = (s, body, to = 'human') => call(s, s.coder, 'message.send', { to, body, class: 'INFO' }).id;
test('messages pages all human messages newest first without writes; read state is history', t => {
const s = broker(t), ids = [];
for (let i = 0; i < 205; i++) ids.push(send(s, `note ${i}`));
send(s, 'role only', 'pm');
const before = s.store.all('SELECT * FROM deliveries');
const first = s.read('messages');
assert.equal(first.messages.length, 50);
assert.equal(first.total, 205); assert.equal(first.unread, 205);
assert.deepEqual(first.messages.map(m => m.id), ids.slice(-50).reverse());
assert.ok(first.messages.every(m => m.read === false && m.in_reply_to === null));
assert.equal(first.next, ids[155]);
const second = s.read('messages', { limit: 200, after: first.next });
assert.deepEqual(second.messages.map(m => m.id), ids.slice(0,155).reverse());
assert.equal(second.next, null);
assert.equal(s.read('messages', {limit:200}).messages.length,200);
assert.deepEqual(s.store.all('SELECT * FROM deliveries'), before);
assert.deepEqual(call(s,s.human,'messages'), first);
const got = call(s,s.human,'message.receive');
call(s,s.human,'message.read',{id:got[0].id});
const read = s.read('messages', {limit:200, after:ids[1]});
assert.equal(read.messages[0].read,true); assert.equal(read.unread,204);
const delivered = s.read('messages', {limit:200, after:ids[2]}).messages.find(m => m.id === got[1].id);
assert.equal(delivered.read,false,'delivered without a read receipt remains unread');
});
test('messages refuses role callers, invalid paging and cursors outside this human feed', t => {
const s=broker(t); send(s,'hello'); const other=send(s,'role','pm');
for (const args of [{limit:0},{limit:201},{limit:1.5},{limit:'2'},{after:''},{after:3},{extra:true}])
assert.throws(()=>s.read('messages',args),{code:'invalid-request'});
for (const after of ['missing',other]) assert.throws(()=>s.read('messages',{after}),{code:'message-not-found'});
assert.throws(()=>call(s,s.coder,'messages'),{code:'human-required'});
const cap=s.b.bindReader({business:'demo'});
assert.throws(()=>call(s,cap,'message.receive'),{code:'read-only'});
});
test('views messages passes only its paging arguments through', async()=>{
const calls=[]; const v=views({call:async(...args)=>(calls.push(args),{messages:[],next:null})});
await v.messages({limit:2,after:'m-1'}); await v.messages();
assert.deepEqual(calls,[['messages',{limit:2,after:'m-1'}],['messages',{}]]);
});
test('messages isolates businesses even with a valid foreign cursor', t => {
const s=broker(t);
// A second broker shares this test's store but binds a separate business.
const otherBroker=new Broker({store:s.store,businesses:{other:{...BUSINESSES.demo,id:'other'}}});
const agent=otherBroker.bindLaunch({business:'other',role:'coder',run:'other-run',harness:'pi'});
otherBroker.request(agent,{verb:'role.claim'});
const foreign=otherBroker.request(agent,{verb:'message.send',args:{to:'human',body:'foreign'}}).id;
const own=send(s,'own');
assert.deepEqual(s.read('messages').messages.map(m=>m.id),[own]);
assert.throws(()=>s.read('messages',{after:foreign}),{code:'message-not-found'});
const reader=otherBroker.bindReader({business:'other'});
assert.deepEqual(otherBroker.request(reader,{verb:'messages'}).messages.map(m=>m.id),[foreign]);
});
+10 -3
View File
@@ -29,7 +29,8 @@ mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow [--business <id>]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
@@ -48,7 +49,9 @@ mosaic launches list [--json]
these commands.** The tests use a stand-in transport.
- `inbox` lists the open decisions routed to the human, gated first, in
broker order. For a gated decision it prints what approving authorizes:
the action, its target, and the one choice that authorizes it.
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.
- `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
@@ -62,7 +65,11 @@ mosaic launches list [--json]
inbox every 2 s for up to `--wait` seconds (default 900). It prints every
message that arrives and marks it read, and stops at the reply to its
REQUEST. No reply in time exits 1; the reply may still come, and the next
`mosaic talk` prints it. `--wait 0` only sends.
`mosaic talk` prints it. `--wait 0` only sends. `talk --follow` keeps
printing and marking messages 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.
- `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`.
+55 -19
View File
@@ -29,7 +29,8 @@ export const USAGE = `usage:
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow [--business <id>]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
@@ -170,7 +171,19 @@ async function human(verb, rest, io, deps) {
return print(await call("trail", { subject }), (rows) => formatTrail(subject, rows));
}
if (parsed.positional.length !== 0) throw usage(`${verb} takes no arguments`);
if (verb === "inbox") return print(await call("inbox"), formatInbox);
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);
return print({ decisions, messages }, value => formatInbox(value.decisions) +
`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(""));
}
if (verb === "tasks") return print(await call("tasks"), formatTasks);
return print(await call("agents"), formatAgents);
}
@@ -184,11 +197,14 @@ function formatMessage(m) {
}
// `mosaic talk <role> <text>`: a REQUEST to the role, then the human's inbox
// until the reply arrives or --wait runs out. Every message received on the
// way is printed and marked read, so a late reply shows up on the next talk.
async function talk(rest, io, deps) {
const parsed = parse(rest, { values: ["--business", "--wait"] });
if (parsed.positional.length !== 2) throw usage("talk takes a role and one quoted text");
// until the reply arrives or --wait runs out. --follow keeps receiving after
// that reply; follow starts the same receive loop without sending.
async function talk(rest, io, deps, receiveOnly = false) {
const parsed = parse(rest, { flags: ["--follow"], values: ["--business", ...(receiveOnly ? [] : ["--wait"])] });
if (parsed.positional.length !== (receiveOnly ? 0 : 2)) throw usage(receiveOnly ? "follow takes no arguments" : "talk takes a role and one quoted text");
if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows");
const follow = receiveOnly || parsed.flags.has("--follow");
if (follow && parsed.values["--wait"] !== undefined) throw usage("--follow and --wait cannot be combined");
const wait = parsed.values["--wait"] === undefined ? TALK_WAIT_S : Number(parsed.values["--wait"]);
if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more");
refuseInsideAgent(io.env);
@@ -196,21 +212,40 @@ async function talk(rest, io, deps) {
const business = businessFor(parsed, system.dataRoot);
const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
const [to, body] = parsed.positional;
const sent = await call("message.send", { to, body, class: "REQUEST" });
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
if (wait === 0) return;
let sent;
if (!receiveOnly) {
sent = await call("message.send", { to, body, class: "REQUEST" });
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
if (!follow && wait === 0) return;
}
const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
const deadline = Date.now() + wait * 1000;
for (;;) {
let answered = false;
for (const m of await call("message.receive")) {
io.stdout.write(formatMessage(m));
await call("message.read", { id: m.id });
if (m.in_reply_to === sent.id) answered = true;
const controller = new AbortController();
const stop = () => controller.abort();
// Only continuous commands take ownership of Ctrl-C, and release it on every exit.
if (follow) process.on("SIGINT", stop);
deps.signal?.addEventListener("abort", stop, { once: true });
if (deps.signal?.aborted) stop();
try {
while (!controller.signal.aborted) {
let answered = false;
for (const m of await call("message.receive")) {
io.stdout.write(formatMessage(m));
await call("message.read", { id: m.id });
if (m.in_reply_to === sent?.id) answered = true;
}
if (!follow && answered) return;
if (!follow && Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk shows it`, 1);
if (controller.signal.aborted) break;
await new Promise(resolve => {
const wake = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", wake); resolve(); };
const timer = setTimeout(wake, pollMs);
controller.signal.addEventListener("abort", wake, { once: true });
});
}
if (answered) return;
if (Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk shows it`, 1);
await new Promise((r) => setTimeout(r, pollMs));
} finally {
if (follow) process.removeListener("SIGINT", stop);
deps.signal?.removeEventListener("abort", stop);
}
}
@@ -336,6 +371,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "talk") return talk(rest, io, deps);
if (verb === "follow") return talk(rest, io, deps, true);
if (verb === "stop") return stop(rest, io, deps);
if (verb === "launches") return launches(rest, io, deps);
if (verb === "bus") return bus(rest, io, deps);
+2 -2
View File
@@ -40,7 +40,7 @@ test("inbox lists only decisions routed to the human, with what approving author
assert.match(text, new RegExp(`decide: mosaic decide ${gated.id.slice(0, 8)} <option>`));
const json = await s.run(["inbox", "--business", "demo", "--json"]);
assert.equal(JSON.parse(json.io.out.text)[0].id, gated.id);
assert.deepEqual(s.calls.map((c) => c.verb), ["inbox", "inbox"]);
assert.deepEqual(s.calls.map((c) => c.verb), ["inbox", "messages", "inbox"]);
});
test("decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow", async (t) => {
@@ -139,7 +139,7 @@ test("without --business a command uses the live host's business, and a stale ho
assert.equal(s.calls.length, 0);
write(startTimeOf(process.pid));
assert.equal((await s.run(["inbox"])).code, 0);
assert.deepEqual(s.calls.map((c) => c.business), ["demo"]);
assert.deepEqual(s.calls.map((c) => c.business), ["demo", "demo"]);
});
test("every human command refuses inside an agent run before it touches the bus", async (t) => {
+86
View File
@@ -0,0 +1,86 @@
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);
});
+28 -2
View File
@@ -276,7 +276,7 @@ delivery may be unknown: inspect the seat before sending again.
## Inbox, tasks, agents and trails (slice 1 S5, #1522)
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/messages`, `#/tasks`,
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>`,
`#/trail/decision/<id>` and `#/trail/message/<id>` (row 76), with `?kind=` filters on a trail. The design
note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
@@ -466,7 +466,8 @@ session reader or transport is implemented here. The session reader is
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent`,
`/api/bus/credentials` and `/api/bus/replies?id=<message id>` are the bus
`/api/bus/credentials`, `/api/bus/replies?id=<message id>` and
`/api/bus/messages?limit=50&after=<message id>` are the bus
reads (`src/bus.mjs`). Any other method is 405. The subject and the id must
match the broker's identifier rule, and the list must be `roles` or
`recent`, else 400. `replies` (row 81, #1575) is the messages whose
@@ -633,3 +634,28 @@ say-so, not a test result. Keep #1507 open pending that ruling.
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
Seen and reply actions already taken belong to the board and are not rolled back.
## Messages (row 94, #1588)
The Messages page shows every message addressed to the human, newest first,
including unsolicited messages and late replies. Each lists sender, class,
time, read state, literal body and a trail link. It polls at the existing
10-second cadence; pause and refresh work as on the other pages. Older
messages and Newest messages links page through the history. The nav count
is the whole feed's unread count, including messages beyond the visible
page. If that count cannot be read, it is hidden; a bus refusal clears kept
data as on the other pages. Reading this page marks nothing delivered or
read. `mosaic follow` or `mosaic talk --follow` prints messages and marks
them read in the terminal. The Console does not consume them.
The messages HTTP read accepts only limit (1–200, default 50) and after
(a message id cursor); malformed, duplicate or unknown query keys refuse
400 invalid-request. It returns rows plus total, unread, next and the
Console read time at. Older read routes retain their rows/at response.
The broker owns business scoping and cursor authorization.
`messages.test.mjs` exercises the route's paging/refusals and existing
route response shapes, then the page against a temporary broker with
a scheduled poll accelerated in the browser test. It proves literal
content, late unsolicited arrival, pagination, full unread count and
unchanged delivery receipts. No live bus data or service is used.
+3
View File
@@ -39,3 +39,6 @@ table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;col
@media(max-width:699px){.s1-trow{grid-template-columns:3.6em minmax(0,1fr)}.s1-ttext{grid-column:1/-1}}
@media(max-width:959px){.s1-sections{display:flex;flex-wrap:wrap;flex-basis:100%;gap:6px}.s1-section{border-color:var(--border);border-radius:99px;padding:5px 12px}}
@media (forced-colors:active){.s1-section[aria-current=page]{outline:2px solid Highlight}.s1-trow::before{background:CanvasText}}
/* Human message bodies keep their line breaks and wrap long literal text. */
#message-list .s1-message { white-space: pre-wrap; overflow-wrap: anywhere; }
+29 -9
View File
@@ -34,12 +34,12 @@
async function fetchRead(verb, subject) {
if (out()) throw signedOut();
let res, body;
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : verb === 'messages' ? 'after' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
return { rows: body.rows, at: body.at };
return { ...body, rows: body.rows, at: body.at };
}
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
// error carries the module's own message, which the server has already redacted.
@@ -62,6 +62,7 @@
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
used.push(r.at);
if (verb === 'messages') { messageCount = r.unread; return r; }
return r.rows;
};
get.api = async path => {
@@ -235,9 +236,9 @@
const user = u => `user ${esc(u)}`;
// ---------------------------------------------------------------- views
let inboxCount = null;
let inboxCount = null, messageCount = null;
function sections(route) {
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
@@ -312,6 +313,17 @@
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
}
async function messagesView(get, q) {
const after = q.get('after');
if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.');
const page = await get('messages', after === null ? undefined : after);
const body = page.rows.length ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${txt(m.body)}</p></li>`).join('')}</ul>`
: empty('No messages to you.', 'Messages appear here as the bus is read.');
return `${head('Messages', `${readNote()} Every message to you, newest first. Reading here marks nothing read.`)}
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
<nav aria-label="Message pages">${after ? '<a href="#/messages">Newest messages</a> · ' : ''}${page.next ? `<a data-older href="#/messages?after=${enc(page.next)}">Older messages</a>` : ''}</nav>`;
}
async function decisionView(get, id) {
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
if (!d) return notFound(`No decision with id ${txt(id)}.`);
@@ -621,7 +633,7 @@
// A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time.
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code);
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
function forget() { last.clear(); inboxCount = null; }
function forget() { last.clear(); inboxCount = null; messageCount = null; }
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// Freshness line in the command bar for bus pages.
@@ -642,6 +654,7 @@
const [a, b, c] = parts, n = parts.length;
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
if (a === 'agents' && n === 1) return agentsView(get);
@@ -690,15 +703,17 @@
// The board page keeps its own title; the Inbox count still follows the bus.
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
const [inbox, agents] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents')]);
const failed = [inbox, agents].filter(s => s.status === 'rejected').map(s => s.reason);
const [inbox, agents, messages] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents'), fetchRead('messages')]);
const failed = [inbox, agents, messages].filter(s => s.status === 'rejected').map(s => s.reason);
if (failed.some(err => err.code === 'signed-out')) return;
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
if (agents.status === 'fulfilled') last.set('agents', agents.value);
if (messages.status === 'fulfilled') last.set('messages', messages.value);
const refused = failed.find(refusal);
if (refused) forget();
if (g !== gen) return;
if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
if (messages.status === 'fulfilled' && !refused) messageCount = messages.value.unread;
paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null));
sections(path); schedule();
return;
@@ -706,7 +721,12 @@
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
let html, state = 'fresh';
const live = reader(true);
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
try {
if (path !== '/messages') {
try { await live('messages'); }
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
}
html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
catch (err) {
if (err.code === 'signed-out') return;
const kept = reader(false);
@@ -771,7 +791,7 @@
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
// back where it was. It only navigates.
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
function pkItems() {
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
+20 -1
View File
@@ -49,7 +49,7 @@ async function body(req, max = 4096) {
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81
// (#1575) adds `replies`: the messages answering one message, for the send receipt.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'];
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages'];
const DECISION_LISTS = ['roles', 'recent'];
async function busRead(res, bus, verb, search) {
let arg;
@@ -65,6 +65,25 @@ async function busRead(res, bus, verb, search) {
arg = new URLSearchParams(search).get('id');
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
}
if (verb === 'messages') {
const query = new URLSearchParams(search), args = {};
const invalid = [...query.keys()].some(k => !['limit', 'after'].includes(k) || query.getAll(k).length !== 1);
if (invalid || (query.has('limit') && (!/^[1-9]\d*$/.test(query.get('limit')) || Number(query.get('limit')) > 200)) ||
(query.has('after') && !subjectOk(query.get('after'))))
return json(res, 400, { error: 'invalid-request', message: 'messages takes limit (1–200) and an after message id' });
if (query.has('limit')) args.limit = Number(query.get('limit'));
if (query.has('after')) args.after = query.get('after');
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const page = await bus.messages(args);
if (!page || !Array.isArray(page.messages)) throw new BusReadError('invalid-response');
const { messages: rows, ...metadata } = page;
return json(res, 200, { rows, ...metadata, at: new Date().toISOString() });
} catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const rows = await bus[verb](arg);
+1 -1
View File
@@ -316,7 +316,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
assert.ok(requests.some(([, u]) => u === '/api/task-fields'));
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'messages', 'tasks', 'trail']);
// A Console with no bus configured says so, and its board still works.
await b.navigate(`${f.base}/#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent==="No bus to read"');
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {broker} from '../../cli/tests/helpers.mjs';
import {views} from '../../bus/src/views.mjs';
import {startServer} from '../src/serve.mjs';
import {close} from './fixture.mjs';
import {browser} from './browser.mjs';
import {BusReadError} from '../src/bus.mjs';
function seed(t){
const s=broker(t),call=(verb,args={})=>s.b.request(s.reader,{verb,args});
const bus=views({call:async(...args)=>structuredClone(call(...args))});
const send=body=>s.b.request(s.coder,{verb:'message.send',args:{to:'human',body,class:'INFO'}}).id;
return {...s,bus,send};
}
test('messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged',async t=>{
const s=seed(t),a=s.send('first'),b=s.send('second');
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});t.after(()=>close(web));
const url=`http://127.0.0.1:${web.address().port}/api/bus/`;
const get=async path=>{const res=await fetch(url+path);return {status:res.status,body:await res.json()}};
const page=await get('messages?limit=1');assert.equal(page.status,200);
assert.deepEqual(page.body.rows.map(m=>m.id),[b]); assert.equal(page.body.total,2);assert.equal(page.body.unread,2);assert.equal(page.body.next,b);
const next=await get(`messages?after=${b}&limit=1`);assert.deepEqual(next.body.rows.map(m=>m.id),[a]);assert.equal(next.body.next,null);
for(const query of ['limit=0','limit=201','limit=1.5','limit=no','limit=','after=','after=%3Cb%3E','limit=2&limit=3','after=x&after=y','extra=1']){
const res=await get('messages?'+query);assert.equal(res.status,400,query);assert.equal(res.body.error,'invalid-request');
}
for(const verb of ['inbox','tasks','agents','replies?id='+a,'decisions?list=recent','credentials','trail?subject='+a]){
const res=await get(verb);assert.equal(res.status,200,verb);assert.deepEqual(Object.keys(res.body).sort(),['at','rows']);
const [name,query='']=verb.split('?'),params=new URLSearchParams(query);
const args=Object.fromEntries(params);
assert.deepEqual(res.body.rows,structuredClone(s.b.request(s.reader,{verb:name,args})),verb);
assert.match(res.body.at,/^\d{4}-.*Z$/);
}
});
test('Console Messages lists late unsolicited messages on polling with sender class time read state and trail; nav count includes beyond the page; reads never deliver', {timeout:45000},async t=>{
const s=seed(t),early=s.send('early');
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});
const b=await browser({machine:false});
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out')})()`);
try{
// Reach a real scheduled poll without making a test wait ten seconds.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'const originalTimeout=window.setTimeout; window.setTimeout=(fn,ms,...args)=>originalTimeout(fn,ms===10000?100:ms,...args);'});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/messages`);
await wait('document.querySelector("#message-list")');
const late=s.send('<img src=x onerror="window.injected=1"> late unsolicited');
const before=s.store.all('SELECT * FROM deliveries');
await wait('document.querySelector("#message-list")?.textContent.includes("late unsolicited")');
assert.deepEqual(s.store.all('SELECT * FROM deliveries'),before);
const items=await b.evaluate('[...document.querySelectorAll("#message-list li")].map(e=>({text:e.textContent,href:e.querySelector("a").getAttribute("href")}))');
assert.match(items[0].text,/coder.*INFO.*Unread/s);assert.match(await b.evaluate('document.querySelector("#message-list time").getAttribute("datetime")'), /^\d{4}-.*Z$/);
await b.viewport(400,900);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
if(process.env.ROCKO_MESSAGES_SHOT) await b.screenshot(process.env.ROCKO_MESSAGES_SHOT);
assert.equal(items[0].href,`#/trail/message/${late}`);assert.equal(items[1].href,`#/trail/message/${early}`);
assert.equal(await b.evaluate('!!window.injected || !!document.querySelector("#message-list img")'),false);
for(let i=0;i<52;i++)s.send(`backlog ${i}`);
await wait(`document.querySelector('#sections a[href="#/messages"]')?.textContent.includes("54")`);
await wait('document.querySelectorAll("#message-list li").length===50');
assert.ok(await b.evaluate('!!document.querySelector("#bus-view a[data-older]")'));
await b.evaluate('document.querySelector("#bus-view a[data-older]").click(),true');
await wait('document.querySelectorAll("#message-list li").length===4');
assert.match(await b.evaluate('document.querySelector("#message-list").textContent'),/late unsolicited/);
const got=s.b.request(s.human,{verb:'message.receive'});
for(const m of got)s.b.request(s.human,{verb:'message.read',args:{id:m.id}});
await wait('document.querySelector("#message-list")?.textContent.includes("Read") && !document.querySelector("#message-list")?.textContent.includes("Unread")');
assert.equal(await b.evaluate(`document.querySelector('#sections a[href="#/messages"] .count').textContent`),'0');
}finally{await b.close();await close(web)}
});
test('Messages nav count polls on Tasks and Board and clears on a Board refusal; messages 403 refuses local pages', {timeout:45000}, async t=>{
const s=seed(t);s.send('one');s.send('two');
let messagesFailure=null;
const bus={...s.bus,messages:async args=>{if(messagesFailure)throw new BusReadError(messagesFailure);return s.bus.messages(args);}};
const at=()=>new Date().toISOString(), localReads=[];
const reads={
queue:async()=>{localReads.push('queue');return {rows:[],notes:[],at:at()};},
business:async()=>{localReads.push('business');return {business:{id:'demo',human:'jason',arbiters:{},projects:[],vars:{},instances:[],actions:[],credentials:[]},at:at()};},
settings:async()=>{localReads.push('settings');return {release:'0.0.1',business:'demo',notifier:{binding:null},at:at()};},
};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads});
const b=await browser({machine:false});
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out: '+${JSON.stringify(expr)})})()`);
const badge=`document.querySelector('#sections a[href="#/messages"] .count')`;
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'const originalTimeout=window.setTimeout; window.setTimeout=(fn,ms,...args)=>originalTimeout(fn,ms===10000?100:ms,...args);'});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/tasks`);
await wait(`document.querySelector('#bus-view h1')?.textContent==='Tasks' && ${badge}?.textContent==='2'`);
s.send('three');await wait(`${badge}?.textContent==='3'`);
await b.evaluate('location.hash="#/",true');
await wait(`document.querySelector('#bus-view').hidden && document.querySelector('#held')?.textContent.includes('coder') && ${badge}?.textContent==='3'`);
s.send('four');await wait(`${badge}?.textContent==='4'`);
messagesFailure='human-required';
await wait(`!${badge} && document.querySelector('#held')?.textContent.includes('refused')`);
messagesFailure=null;await wait(`${badge}?.textContent==='4'`);
for(const [path,title] of [['queue','Queue'],['business','Business demo'],['settings','Settings']]){
await b.evaluate(`location.hash=${JSON.stringify('#/'+path)},true`);
await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${badge}?.textContent==='4'`);
const before=localReads.length;
messagesFailure='human-required';
await wait(`!!document.querySelector('#bus-view .banner.ref') && !${badge}`);
assert.match(await b.evaluate('document.querySelector("#bus-view h1").textContent'),/Bus refused the read/);
assert.equal(localReads.length,before,'a refused messages pre-read prevents the local read');
messagesFailure=null;
await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${badge}?.textContent==='4'`);
}
}finally{await b.close();await close(web)}
});
+2 -2
View File
@@ -263,7 +263,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
}
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 7);
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 8);
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
@@ -459,7 +459,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
}
// After a refusal the palette lists only the views: no kept decision or task.
const views = ['#/', '#/inbox', '#/tasks', '#/agents', '#/queue', '#/business', '#/settings'];
const views = ['#/', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/settings'];
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
+1 -1
View File
@@ -56,7 +56,7 @@ async function page(b, base) {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
await b.viewport(400, 900);
await b.navigate(base);
await wait('document.querySelectorAll("#sections a").length===7');
await wait('document.querySelectorAll("#sections a").length===8');
return { wait, text, count, flat, refresh, go };
}