Files
stack/packages/bus/tests/messaging.test.mjs
T
jason.woltjeandClaude Opus 5.5 2c16d6b932 bus: projects and threads, schema 3d to 3e (row 121, #1615)
Darkwing's row 121 round 2 (agents/darkwing/work/queue-121/r2, base
20d2b5788, manifest 710d5086...e67e9b, 30 files), applied unchanged on
d7ebaa9a5; no commit since the base touches any of the 30 paths. Dewey
asked for changes on round 1 in comment 28591 (F1 to F6) and approved
round 2 in 28615. Landing gate on d7ebaa9a5 plus the candidate: all 24
suites passed, 0 failed.

F6 and Dewey's three round 2 notes are follow-ups (sage-0369, P19 task
311). One of them corrects the packet: BUILD.md says the decision-route
setup's projects key is needed, and Dewey's probe showed project.create
reads it only with repo, so the key is dead setup.

The live bus stays at 3b until Mos's schema window migrates it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:06:05 -05:00

312 lines
15 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
// Row 120 (#1614, REQ-ASSIST-2): the broker limits who a role sends to and hears from.
// The assistant's lists are read from the shipped roles/assistant.json, so the test pins the file
// the stack runs, not a copy of it.
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const assistantLists = shipped('assistant').messaging;
const within = { authority: { withinRole: ['message.send'], crossRole: [] } };
// Instance names are the business's; the lists name definitions.
const roles = {
chief: { definition: 'chief-of-staff', ...within },
assistant: { definition: 'assistant', ...within },
pm: { definition: 'pm', ...within },
coder: {
definition: 'coder',
authority: { withinRole: ['message.send', 'git.push.working', 'review.request'], crossRole: [] },
},
scribe: { definition: 'scribe', ...within },
warden: { definition: 'warden', ...within },
reviewer: {
definition: 'reviewer',
authority: { withinRole: ['message.send', 'review.verdict'], crossRole: [] },
},
};
const definitions = {
'chief-of-staff': {},
assistant: { messaging: assistantLists },
pm: {},
coder: {},
reviewer: {},
// Only a send limit: tells a dropped sender-side check apart from the target-side check.
scribe: { messaging: { sendTo: ['pm'], receiveFrom: ['human', 'pm', 'coder', 'warden', 'chief-of-staff'] } },
// Only a receive limit: the same for the target side.
warden: { messaging: { sendTo: ['human', 'pm', 'coder', 'scribe'], receiveFrom: ['pm'] } },
};
const demo = (extra = {}) => ({
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles,
definitions,
projects: { stack: { root: '/nonexistent' } },
vars: { 'bus.tester': true },
...extra,
},
});
function setup(t, businesses = demo()) {
const root = mkdtempSync(join(tmpdir(), 'bus-messaging-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const b = new Broker({ store, businesses });
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// Row 121 (#1615): the assistant runs for one thread, and the user writes to it with thread.send.
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
for (const role of Object.keys(roles)) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const send = (from, to, extra = {}) =>
from === 'human' && to === 'assistant'
? call(from, 'thread.send', { thread, body: 'hello', ...extra })
: call(from, 'message.send', { to, body: from === 'tester' ? '[mos test] hello' : 'hello', ...extra });
const count = (sql) => store.get(sql).n;
const refusals = () =>
store
.all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq")
.map((e) => [e.actor_role, JSON.parse(e.body).code]);
return { b, store, caps, call, send, count, refusals, thread };
}
const RECIPIENT = 'message-recipient-refused';
const SENDER = 'message-sender-refused';
test('the assistant sends to the user and to the chief of staff only', (t) => {
const { send, store } = setup(t);
assert.deepEqual(assistantLists, { sendTo: ['human', 'chief-of-staff'], receiveFrom: ['human', 'chief-of-staff'] });
for (const to of ['human', 'chief']) {
const sent = send('assistant', to);
const row = store.get('SELECT from_role, to_role FROM messages WHERE id=?', sent.id);
assert.deepEqual({ ...row }, { from_role: 'assistant', to_role: to });
}
});
test('an assistant send to any other role, or to the tester, is refused and logged', (t) => {
const { send, store, count, refusals } = setup(t);
const asked = send('tester', 'pm', { class: 'REQUEST' });
const messages = count('SELECT count(*) n FROM messages');
const allowed = count("SELECT count(*) n FROM events WHERE kind='action.allowed'");
const others = ['pm', 'coder', 'scribe', 'warden'];
for (const to of others) assert.throws(() => send('assistant', to), { code: RECIPIENT }, to);
// A reply to a real tester message is the only way past the tester gate; the list still refuses it.
assert.throws(() => send('assistant', 'tester', { in_reply_to: asked.id }), { code: RECIPIENT }, 'tester');
assert.deepEqual(
refusals(),
[...others, 'tester'].map(() => ['assistant', RECIPIENT]),
);
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
assert.equal(
count("SELECT count(*) n FROM events WHERE kind='action.allowed'"),
allowed,
'a refused send consumes no authority',
);
assert.equal(store.get("SELECT count(*) n FROM messages WHERE from_role='assistant'").n, 0);
});
test('only the user and the chief of staff may send to the assistant; the tester is refused by default', (t) => {
const { send, store, count, refusals } = setup(t);
// The chief writes to the assistant in reply to the assistant's own message (row 121).
const opened = send('assistant', 'chief');
for (const from of ['human', 'chief']) {
const sent = send(from, 'assistant', from === 'chief' ? { in_reply_to: opened.id } : {});
assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', sent.id).to_role, 'assistant');
}
const messages = count('SELECT count(*) n FROM messages');
// Senders without a list of their own, so only the assistant's receive list can refuse them.
const others = ['pm', 'coder', 'tester'];
for (const from of others) assert.throws(() => send(from, 'assistant'), { code: SENDER }, from);
assert.deepEqual(refusals(), others.map((from) => [from, SENDER]));
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role='assistant' AND from_role='tester'"), 0);
});
// Row 121 round 2 (#1615, Dewey's F1 in 28591): thread.send is the user's only way to the
// assistant, so it runs the same check as message.send.
test('REQ-ASSIST-2 REQ-THREAD-1 thread.send to an instance that does not hear from the user is refused and stores nothing', (t) => {
const businesses = demo({
definitions: { ...definitions, assistant: { messaging: { sendTo: ['human', 'chief-of-staff'], receiveFrom: ['chief-of-staff'] } } },
});
const { send, count, refusals } = setup(t, businesses);
const messages = count('SELECT count(*) n FROM messages');
assert.throws(() => send('human', 'assistant'), { code: SENDER });
assert.deepEqual(refusals(), [[null, SENDER]]);
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
assert.equal(count('SELECT count(*) n FROM message_threads'), 0, 'nothing filed in the thread');
assert.equal(count("SELECT count(*) n FROM events WHERE kind='human.input'"), 0, 'no instruction event');
});
test('a sender-side list alone refuses a send to a role that has no list', (t) => {
const { send, count, refusals } = setup(t);
send('scribe', 'pm');
assert.throws(() => send('scribe', 'coder'), { code: RECIPIENT });
assert.throws(() => send('scribe', 'human'), { code: RECIPIENT });
assert.deepEqual(refusals(), [['scribe', RECIPIENT], ['scribe', RECIPIENT]]);
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role='scribe'"), 1);
});
test('a target-side list alone refuses a send from a role that has no list', (t) => {
const { send, count, refusals } = setup(t);
send('pm', 'warden');
for (const from of ['coder', 'chief', 'human', 'tester'])
assert.throws(() => send(from, 'warden'), { code: SENDER }, from);
assert.deepEqual(refusals(), ['coder', 'chief', 'human', 'tester'].map((from) => [from === 'human' ? null : from, SENDER]));
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role='warden'"), 1);
});
test('the sender list is checked before the target list', (t) => {
const { send, refusals } = setup(t);
// The scribe may not send to the warden, and the warden may not hear from the scribe: one code.
assert.throws(() => send('scribe', 'warden'), { code: RECIPIENT });
assert.deepEqual(refusals(), [['scribe', RECIPIENT]]);
});
test('roles without the field behave exactly as before', (t) => {
const { send, call, count, refusals } = setup(t);
for (const [from, to] of [
['pm', 'coder'],
['coder', 'pm'],
['chief', 'pm'],
['pm', 'chief'],
['pm', 'human'],
['human', 'coder'],
['tester', 'pm'],
['tester', 'coder'],
])
send(from, to);
assert.deepEqual(refusals(), []);
assert.equal(count('SELECT count(*) n FROM messages'), 8);
// A role still answers a tester only in reply, whatever its lists.
assert.throws(() => send('pm', 'tester'), { code: 'tester-reply-required' });
const asked = send('tester', 'coder', { class: 'REQUEST' });
send('coder', 'tester', { in_reply_to: asked.id });
assert.equal(call('pm', 'message.receive').length > 0, true);
});
test('a business with no role definitions at all is unlimited', (t) => {
const businesses = demo();
delete businesses.demo.definitions;
const { send, refusals } = setup(t, businesses);
// A send to the assistant still answers a message in its thread (row 121).
const opened = send('assistant', 'pm');
for (const from of ['pm', 'tester']) send(from, 'assistant', { in_reply_to: opened.id });
assert.deepEqual(refusals(), []);
});
test('a malformed messaging field refuses at boot', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-messaging-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const boot = (messaging) => () =>
new Broker({ store, businesses: demo({ definitions: { ...definitions, pm: { messaging } } }) });
const ok = { sendTo: [], receiveFrom: ['human'] };
boot(ok)();
for (const bad of [
'human',
[],
{ sendTo: [] },
{ sendTo: 'human', receiveFrom: [] },
{ sendTo: ['tester'], receiveFrom: [] },
{ sendTo: [], receiveFrom: ['tester'] },
{ sendTo: [''], receiveFrom: [] },
{ sendTo: [7], receiveFrom: [] },
])
assert.throws(boot(bad), { code: 'invalid-messaging' }, JSON.stringify(bad));
});
// Round 1 F1: review.request and review.verdict store a message too, so the same lists bind them.
// REQ-ASSIST-2 has no approval exception, so an approved decision doesn't open a path.
const TASK = 'vikunja:1/2';
const COMMIT = 'a'.repeat(40);
const options = [
{ key: 'yes', text: 'Allow' },
{ key: 'no', text: 'Decline' },
];
const pushed = (b, caps) => {
b.checkPush(caps.coder, TASK);
b.recordPush(caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit: COMMIT, previous: null });
};
// The assistant holds no review.request, so it raises a decision for it, and the human approves.
const approved = (call) => {
const d = call('assistant', 'decision.raise', {
action: 'review.request',
target: TASK,
question: 'review?',
options,
recommendation: 'yes',
blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
return d.id;
};
test("the assistant's review.request to a reviewer is refused and logged, even with an approved decision", (t) => {
const { b, caps, call, count, refusals } = setup(t);
pushed(b, caps);
const decision = approved(call);
const messages = count('SELECT count(*) n FROM messages');
const used = "SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='review.request'";
const before = count(used);
const ask = () => call('assistant', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer', decision });
// The refusal leaves the decision unconsumed, so a retry meets the same list, not decision-consumed.
assert.throws(ask, { code: RECIPIENT });
assert.throws(ask, { code: RECIPIENT });
assert.deepEqual(refusals(), [['assistant', RECIPIENT], ['assistant', RECIPIENT]]);
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no REVIEW-REQUEST stored');
assert.equal(count("SELECT count(*) n FROM events WHERE kind='review.requested'"), 0);
assert.equal(count(used), before, 'a refused request consumes no authority');
});
test('a review.verdict to the assistant from a role outside its receiveFrom is refused and logged', (t) => {
// A request from before the lists existed: a first broker without the assistant's lists lets it through.
const first = setup(t, demo({ definitions: { ...definitions, assistant: {} } }));
pushed(first.b, first.caps);
const decision = approved(first.call);
const asked = first.call('assistant', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer', decision });
// A second broker on the same store enforces the shipped lists.
first.call('reviewer', 'role.release');
const b2 = new Broker({ store: first.store, businesses: demo() });
const cap = b2.bindLaunch({ business: 'demo', role: 'reviewer', run: 'reviewer-run-2', harness: 'pi' });
b2.request(cap, { verb: 'role.claim' });
const args = { task_ref: TASK, request: asked.request, verdict: 'approve', body: 'looks fine' };
assert.throws(() => b2.request(cap, { verb: 'review.verdict', args }), { code: SENDER });
assert.deepEqual(first.refusals(), [['reviewer', SENDER]]);
assert.equal(first.count("SELECT count(*) n FROM messages WHERE class='REVIEW-RESULT'"), 0, 'no REVIEW-RESULT stored');
assert.equal(first.count("SELECT count(*) n FROM events WHERE kind='review.verdict'"), 0);
assert.equal(
first.count("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='review.verdict'"),
0,
'a refused verdict consumes no authority',
);
});
test('review requests and verdicts between roles without the field are unchanged', (t) => {
const { b, caps, call, store, refusals } = setup(t);
pushed(b, caps);
const asked = call('coder', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer' });
const verdict = call('reviewer', 'review.verdict', { task_ref: TASK, request: asked.request, verdict: 'approve', body: 'ok' });
assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', asked.message).to_role, 'reviewer');
assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', verdict.message).to_role, 'coder');
assert.deepEqual(refusals(), []);
});