Files
stack/packages/bus/tests/replies.test.mjs
T
jason.woltjeandClaude Opus 5.5 c9a5650ed0 bus, webui, cli: the Console shows the reply to a web send (#1575)
Row 81. The broker gains a `replies` read verb for human and reader
sessions: `{id}`, a message UUID, returns that business's messages whose
in_reply_to is the id, oldest first, at most 50, and writes no
deliveries row, so the reply still reaches the next `mosaic talk`. An
unknown id refuses message-not-found. The Console serves it through
BUS_VERBS with an id check, and the Sent receipt shows the replies to a
web send. The cli's talk timeout message and README now say the reply
may still come and the next `mosaic talk` prints it.

Dewey's round 2, candidate manifest 3ce7527f, base 1e5f5958, a rebase
after row 61. Approved by Darkwing (27783 round 1, 27821 round 2).
Sage's gate green 19:25:18Z to 19:30:39Z. Live at the next bus restart
(the broker verb) and Console restart (the view), bus first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 14:33:11 -05:00

163 lines
8.0 KiB
JavaScript

// Row 81 (#1575): `replies`, the read behind the Console's web-send receipt. The messages
// answering one message, oldest first, at most REPLIES, for human and reader sessions. It
// writes no delivery or read row, so the human's next message.receive (mosaic talk) still
// gets each reply.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, REPLIES } from '../src/broker.mjs';
import { views } from '../src/views.mjs';
const roles = {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
};
const businesses = {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
};
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-replies-'));
const store = new Store(root);
const b = new Broker({ store, businesses });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const agent = (role, business = 'demo') => {
const cap = b.bindLaunch({ business, role, run: `${business}-${role}-run`, harness: 'pi', address: `${business}-${role}-run` });
b.request(cap, { verb: 'role.claim' });
return cap;
};
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = b.bindReader({ business: 'demo' });
const coder = agent('coder');
const pm = agent('pm');
// The human's web send, as row 76's Console makes it.
const sent = call(b, human, 'message.send', { to: 'coder', body: 'Please look at row 81.' });
const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq');
const count = () =>
['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map(
(table) => store.get(`SELECT count(*) AS n FROM ${table}`).n,
);
return { b, store, agent, human, reader, coder, pm, sent, deliveries, count };
}
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
const reply = (b, cap, to, body, extra = {}) =>
call(b, cap, 'message.send', { to: 'human', body, class: 'RESULT', in_reply_to: to, ...extra });
test('a reply is listed for human and reader sessions, and its delivery rows are unchanged', (t) => {
const { b, store, human, reader, coder, pm, sent, deliveries, count } = setup(t);
call(b, coder, 'message.receive');
const answer = reply(b, coder, sent.id, 'Done: 3 files.');
// Not replies to the send: a message to the human with no in_reply_to, and a reply to another message.
call(b, pm, 'message.send', { to: 'human', body: 'fyi' });
const other = call(b, human, 'message.send', { to: 'pm', body: 'Another question' });
reply(b, pm, other.id, 'Another answer');
const before = deliveries();
const counts = count();
for (const cap of [human, reader]) {
const rows = call(b, cap, 'replies', { id: sent.id });
assert.deepEqual(
rows.map((m) => [m.id, m.from_role, m.to_role, m.class, m.in_reply_to, m.body]),
[[answer.id, 'coder', 'human', 'RESULT', sent.id, 'Done: 3 files.']],
);
// The whole row is the messages row trail gives the same session for that id.
const { table, ...m } = call(b, cap, 'trail', { subject: answer.id }).find((r) => r.table === 'messages');
assert.equal(table, 'messages');
assert.deepEqual({ ...rows[0] }, m);
}
assert.deepEqual(deliveries(), before, 'no delivered or read row, nothing else either');
assert.deepEqual(count(), counts, 'a successful read writes nothing');
assert.deepEqual(
store.all("SELECT op FROM deliveries WHERE message=? ORDER BY seq", answer.id).map((d) => d.op),
['routed'],
);
});
test('the reply still reaches the human message.receive (mosaic talk) after the Console showed it', (t) => {
const { b, human, reader, coder, sent } = setup(t);
const answer = reply(b, coder, sent.id, 'Done.');
assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
assert.deepEqual(call(b, human, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
const got = call(b, human, 'message.receive');
assert.deepEqual(got.map((m) => [m.id, m.in_reply_to, m.body]), [[answer.id, sent.id, 'Done.']]);
assert.deepEqual(call(b, human, 'message.read', { id: answer.id }), { read: true });
assert.deepEqual(call(b, human, 'message.receive'), []);
// Once received and read it is still listed: the read is history, not an inbox.
assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
});
test('a message with no reply gives an empty list', (t) => {
const { b, human, reader, sent } = setup(t);
for (const cap of [human, reader]) assert.deepEqual(call(b, cap, 'replies', { id: sent.id }), []);
});
test('replies come oldest first and stop at REPLIES', (t) => {
const { b, reader, coder, pm, sent } = setup(t);
assert.equal(REPLIES, 50);
const ids = [];
for (let i = 0; i <= REPLIES; i++) ids.push(reply(b, i % 2 ? pm : coder, sent.id, `reply ${i}`).id);
const rows = call(b, reader, 'replies', { id: sent.id });
assert.equal(rows.length, REPLIES);
assert.deepEqual(
rows.map((m) => m.id),
ids.slice(0, REPLIES),
);
assert.deepEqual(rows.slice(0, 3).map((m) => [m.from_role, m.body]), [
['coder', 'reply 0'],
['pm', 'reply 1'],
['coder', 'reply 2'],
]);
});
test('an id that names no message in the business refuses message-not-found, and the refusal is recorded', (t) => {
const { b, store, agent, human, reader, pm, sent } = setup(t);
const elsewhere = call(b, agent('coder', 'other'), 'message.send', { to: 'human', body: 'not demo' });
const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n;
for (const cap of [human, reader])
for (const id of ['00000000-0000-4000-8000-000000000000', elsewhere.id, 'vikunja:3/1']) {
const before = refusals();
assert.throws(() => call(b, cap, 'replies', { id }), { code: 'message-not-found' }, id);
assert.equal(refusals(), before + 1, 'the refusal is recorded');
}
// A decision id is not a message id either.
const d = call(b, pm, 'decision.raise', {
action: 'deploy',
target: 'release-1',
question: 'Deploy?',
options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }],
recommendation: 'yes',
blocking: false,
});
assert.throws(() => call(b, reader, 'replies', { id: d.id }), { code: 'message-not-found' });
// The other business's reader finds its own message, not demo's.
const otherReader = b.bindReader({ business: 'other' });
assert.deepEqual(call(b, otherReader, 'replies', { id: elsewhere.id }), []);
assert.throws(() => call(b, otherReader, 'replies', { id: sent.id }), { code: 'message-not-found' });
});
test('strict arguments; a role session gets human-required', (t) => {
const { b, human, reader, coder, pm, sent } = setup(t);
for (const cap of [human, reader])
for (const args of [{}, { id: '' }, { id: 7 }, { id: [sent.id] }, { id: '<b>' }, { id: sent.id, limit: 5 }, { subject: sent.id }])
assert.throws(() => call(b, cap, 'replies', args), { code: 'invalid-request' }, JSON.stringify(args));
// A role can't read the human's replies, even to a message it sent itself.
const own = call(b, pm, 'message.send', { to: 'coder', body: 'hi' });
for (const [cap, id] of [[coder, sent.id], [pm, own.id]])
assert.throws(() => call(b, cap, 'replies', { id }), { code: 'human-required' });
// The reader's other limits stand.
assert.throws(() => call(b, reader, 'message.receive'), { code: 'read-only' });
});
test('views passes replies through as one call with the id', async () => {
const calls = [];
const read = views({ call: async (verb, args) => (calls.push([verb, args]), []) });
assert.deepEqual(await read.replies('m-1'), []);
assert.deepEqual(calls, [['replies', { id: 'm-1' }]]);
});