dewey: row 81 (#1575) packet, round 1

Console shows the reply to a web send: bus `replies` read (human and
reader, message-not-found on unknown ids, oldest first, cap 50, writes
nothing), /api/bus/replies, the Sent receipt's reply list, talk's timeout
names only mosaic talk. Base ff11aa4b, 12 paths, manifest 12/12 on a fresh
worktree. Gate green 18:29-18:35Z; 17/17 mutants killed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 13:36:30 -05:00
co-authored by Claude Opus 5.5
parent 1d4ba6bb5e
commit 5afa8b0fe1
8 changed files with 1082 additions and 0 deletions
@@ -0,0 +1,16 @@
== base bus tests on candidate
ℹ fail 0
ℹ pass 95
ℹ tests 95
== base webui tests on candidate
✖ failing tests:
ℹ fail 1
ℹ pass 80
ℹ tests 81
✖ the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx (9470.85231ms)
== base cli tests on candidate
✖ failing tests:
ℹ fail 1
ℹ pass 113
ℹ tests 114
✖ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1122.336131ms)
+215
View File
@@ -0,0 +1,215 @@
# Row 81 (#1575): Console shows the reply to a web send, round 1
Owner: Dewey. Reviewer: Darkwing, then Sage's gate. Brief:
`docs/plans/2026-10-11_console-reply-view.md` § "Show replies to a message
without receiving them", blob `dd42071d`. Lead decision 97 made the row;
decision 111 started it. Base `ff11aa4b`. None of the candidate's paths
changed between `ff11aa4b` and `refactor` HEAD (`1d4ba6bb`) when the packet
was built.
`build.patch` is `git diff --cached --binary ff11aa4b --` the twelve paths
below. Apply it with `git apply --index` to a detached worktree of the
base; `candidate-manifest.sha256` then checks 12/12 OK.
## What ships
- **Bus read `replies`** (`packages/bus/src/broker.mjs`). `{id}`, nothing
else (`keys` strict, `identifier` on the id). Returns the business's
messages whose `in_reply_to` is that id, `ORDER BY seq`, `LIMIT REPLIES`
(exported, 50). Human and reader sessions only; a role session gets
`human-required`, the same gate as `decisions` and `credentials`. It is
on the reader allowlist. An id naming no message in the session's
business refuses `message-not-found` with the same query `message.send`
uses for `in_reply_to` (`SELECT id FROM messages WHERE id=? AND
business=?`), and the refusal is recorded like every other. It writes
nothing: no `deliveries` row, no event on success.
- **views** passes it through: `replies(id)` calls `('replies', { id })`.
- **webui server**: `GET /api/bus/replies?id=<message id>`. `replies` joins
`BUS_VERBS`; the id must pass `subjectOk` (the broker's identifier rule)
or the server answers 400 `invalid-request` without calling the bus.
`message-not-found` already maps to 400 (`busStatus`). GET only, like the
other reads.
- **Console page** (`src/public/bus.js`). A Sent receipt is followed by the
replies to that message, read with `replies` on every render of the Inbox
(the ten-second refresh and Read again). Each reply shows its role, time,
class, a trail link and the body, through `txt` like every bus string.
"No reply yet" when the list is empty; "could not be read" when the read
failed, with `mosaic talk` named as the other place they print; a note at
50 that only the first 50 show. The receipt paragraph itself is
unchanged, so `.receipt` text stays what row 76's tests check. A new send
starts an empty list. The note under the box now says replies show under
the receipt and that the Console never marks one delivered or read.
- **CLI**: talk's timeout is `no reply from <role> within <n> s; it may
still come, and the next mosaic talk shows it`.
- **READMEs**: webui "Message a role" says where replies show, when they
refresh, what each state means, and that the Console never receives or
marks a message; the route paragraph lists `/api/bus/replies?id=`. bus
README: the verb table row, `replies` in the reader capability list, a
paragraph in "Human and reader paths", and `read.replies(messageId)` in
the shared-reads snippet.
## Decisions and tradeoffs
- **A new verb, not a `trail` field** (the brief left this to me). `trail`
takes a task ref, decision id or message id and returns `[]` for an
unknown subject; the brief says this read refuses an unknown id instead.
Putting replies in `trail` would mean either changing trail's answer for
unknown message ids or giving one field a different refusal rule from
the rest of the call. Trail also returns linked records as a flat list
of mixed tables; replies are a list of messages with their own cap. A
separate verb keeps trail's shape and every existing trail caller as
they are, and lets the Console read the replies without reading the whole
trail on each refresh.
- **Human and reader only.** The brief says "the same sessions as the other
reads (human and reader)". `inbox`/`tasks`/`agents`/`trail` are also open
to role sessions; `decisions` and `credentials` are not. I followed the
brief's wording and the newer two: a role gets `human-required`. A test
checks a role can't read replies even to a message it sent.
- **The receipt id is not checked against the broker before the read.** The
POST answer's id goes to `replies` as is; the server checks its form and
the bus refuses an unknown one. The page shows that as "could not be
read", not "No reply yet", so a bad id is visible.
- **A late answer is dropped.** If a send finishes while a replies read is
out, that read's rows belong to the earlier message. The composer keeps
the receipt object it read for and assigns the rows only if it is still
the current receipt (`receipt === r`). Tested with a held read.
- **A failed replies read doesn't mark the Inbox stale.** It is caught in
the composer, like the agents read for the role list, so the Inbox and
its count stay fresh and only the receipt says the replies couldn't be
read.
## Files
- `packages/bus/src/broker.mjs`: `REPLIES`, the allowlist entry, the
`replies` case.
- `packages/bus/src/views.mjs`: one line.
- `packages/bus/tests/replies.test.mjs`: new, 7 tests.
- `packages/bus/README.md`: verb table row, reader caps, one paragraph,
snippet line.
- `packages/webui/src/serve.mjs`: `BUS_VERBS`, the id check.
- `packages/webui/src/bus.mjs`: comment only.
- `packages/webui/src/public/bus.js`: `fetchRead`'s query name, the replies
state and renderer, the composer read, the reset on send, the note text.
- `packages/webui/tests/bus.test.mjs`: 1 new test, 3 widened.
- `packages/webui/tests/send-browser.test.mjs`: 1 new test, the note
assertion updated.
- `packages/webui/README.md`: "Message a role", the bus route paragraph.
- `packages/cli/src/cli.mjs`: the talk timeout message.
- `packages/cli/tests/verbs.test.mjs`: 1 new test, the timeout assertion.
## Tests
- `packages/bus/tests/replies.test.mjs` (new, 7):
- *a reply is listed for human and reader sessions, and its delivery rows
are unchanged*: a reply to the send is listed; a message to the human
with no `in_reply_to` and a reply to another message are not. Each row
equals the `messages` row `trail` gives for the reply's id. The
`deliveries` rows and the row counts of six tables are the same before
and after; the reply's own deliveries are only `routed`.
- *the reply still reaches the human message.receive (mosaic talk) after
the Console showed it*: listed by reader and human, then received,
read, and still listed.
- *a message with no reply gives an empty list*.
- *replies come oldest first and stop at REPLIES*: 51 replies from two
roles; the first 50 in send order.
- *an id that names no message in the business refuses
message-not-found*: an unknown UUID, another business's message, a task
ref and a decision id, for human and reader; each refusal recorded as
an `action.refused` event. The other business's reader reads its own.
- *strict arguments; a role session gets human-required*: seven bad
argument shapes give `invalid-request`; a role gets `human-required`,
including for a message it sent; the reader still can't
`message.receive`.
- *views passes replies through*.
- `packages/webui/tests/bus.test.mjs`: new *replies route serves the
replies to one message from the reader view* (200 with the row, an empty
list, unknown id 400 `message-not-found`, no delivery row, and seven
missing or malformed ids that never reach the bus, `?subject=` among
them). Widened: the GET-only test (405 on `replies`), the no-bus refusals
(`replies?id=%3C` 400, `replies?id=m-1` 503), the `busReader`
passthrough.
- `packages/webui/tests/send-browser.test.mjs`: new *a sent receipt lists
its replies as the page refreshes, oldest first and capped, and marks
none delivered or read*, in a browser against an in-process broker:
"No reply yet" after the send; after Read again, two replies in order
with role, class, trail link and a hostile body shown as text (no
script or img element, `window.injected` unset), the unrelated messages
absent, deliveries unchanged; a third reply shows on the page's own
ten-second refresh with no click; the human's `message.receive` then gets
all three, and after they are read they are still listed; 51 replies
show 50 and the cap note; an unknown sent id shows "could not be read"
with no stale banner on the Inbox; a new send starts an empty list; a
send that finishes while a replies read is held keeps its own empty list,
and the next read shows its own reply. The row 76 note assertion now
checks the new note.
- `packages/cli/tests/verbs.test.mjs`: the timeout assertion is now the
exact new message. New *a late reply the Console has listed still
reaches the next talk*: talk times out, the role replies, `replies`
lists it, and the next talk prints it and marks only it read.
New tests on base code (`new-tests-on-base.txt`): every new test fails
(`replies.test.mjs` at import, `REPLIES` isn't exported). Base tests on
candidate code (`base-tests-on-candidate.txt`): bus 95/95; webui 80/81 and
cli 113/114, the two failures being the row 76 note text and the old
timeout regex `/mosaic inbox/`, both text the brief changes.
## Mutants (`mutants.py`, `mutants.txt`)
17 single-site mutants, each run in a scratch copy of the candidate against
the test files named in `mutants.py`. All 17 killed:
| mutant | result |
|---|---|
| broker: no human-required | killed |
| broker: no message-not-found | killed |
| broker: newest first | killed |
| broker: cap 51 | killed |
| broker: reader not allowed | killed |
| broker: no strict keys | killed |
| views: subject arg | killed |
| serve: no id check | killed |
| serve: replies not a verb | killed |
| page: query as subject | killed |
| page: no replies read | killed |
| page: no reset on send | killed |
| page: no stale guard | killed |
| page: cap note off by one | killed |
| page: body unescaped | killed |
| page: failure shown as none | killed |
| cli: old message | killed |
`mutants.txt` is the raw output. Its `BAD-ANCHOR (0) serve: no id check`
line is my anchor's wrong indentation (the mutant did not run); the last
line is the rerun with the anchor fixed. The base tests don't reach
`replies` at all, so I didn't tabulate a base column: every new test fails
on base, above.
## Gate (my run on the candidate)
On the candidate in a worktree of `ff11aa4b`, run sequentially,
2026-10-12T18:29:24Z to 18:35:52Z (`gate-summary.txt`):
- `node --test 'packages/<p>/tests/*.test.mjs'`: bus 102/102, webui 83/83,
control-board 124/124, cli 115/115, each rc=0.
- `scripts/test-auth.sh`, `test-conductor.sh`, `test-config.sh`,
`test-discord.sh`, `test-extension-package.sh`, `test-foundation.sh`,
`test-queue.sh`, `test-release.sh`, `test-task.sh`: all rc=0.
The packet's tree is the one gated: `build.patch` applied to a fresh
worktree of `ff11aa4b` checks 12/12 against `candidate-manifest.sha256`.
## For reviewers
- **The bus README's verb table** sits in "Client protocol", not the reads
section the brief names. `decisions` and `credentials` have rows there,
so I added one for `replies`. One line; drop it if the brief's file
boundary is strict.
- **`packages/cli/README.md` lines 61-65** say "the reply still lands in
the inbox". That is the human's message inbox (what `message.receive`
returns), not `mosaic inbox`, so it isn't wrong, but it reads like the
timeout wording this row removed. The file isn't owned; a follow-up could
say "the next `mosaic talk` prints it".
- **Equivalent mutant not run:** dropping `business=?` from the replies
query. `message.send` refuses an `in_reply_to` outside the sender's
business, so no reply can point across businesses and the filter changes
no result. It stays as defence in depth, matching every other read.
+728
View File
@@ -0,0 +1,728 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 11489360..69824a30 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -130,6 +130,7 @@ an operator inspect content and delivery evidence after an uncertain result.
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
+| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
### Agent state (row 59, #1548)
@@ -226,7 +227,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 for one
+capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials/replies for one
business; they do not confer mutation or resolution authority.
`decisions` (row 57, #1546) is for human and reader sessions only; a role
@@ -264,6 +265,18 @@ business file changes after startup: a date, an account, or a credential
added or removed. Rotating a token file without editing its date changes
neither.
+`replies` (row 81, #1575) is also for human and reader sessions only; a role
+session gets `human-required`. It takes `{id}`, nothing else, and returns
+the business's messages whose `in_reply_to` is that id, in broker write
+order, at most `REPLIES` (50). Each row is the `messages` row `trail` gives
+the same session for the reply's id. An id that names no message in the
+session's business refuses `message-not-found`, as `message.send` does for
+`in_reply_to`; `trail` returns an empty list for such an id instead. The
+read writes no `deliveries` row, so a reply the WebUI's Sent receipt lists
+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.
+
## Shared reads and adapter boundary
```js
@@ -273,6 +286,7 @@ const read = views(new Client({path,cap}));
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);
```
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 5acc8329..4389b353 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -40,6 +40,8 @@ const GATED = new Set([
const CLOSED = "('resolved','withdrawn','expired')";
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
export const RECENT = 50;
+// How many replies `replies` returns, oldest first.
+export const REPLIES = 50;
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
@@ -492,7 +494,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'].includes(request.verb))
+ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -906,6 +908,22 @@ 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 [].
+ case 'replies': {
+ if (!s.human && !s.reader) fail('human-required');
+ keys(a, ['id'], ['id']);
+ identifier(a.id);
+ if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business))
+ fail('message-not-found');
+ return this.#store.all(
+ `SELECT * FROM messages WHERE business=? AND in_reply_to=? ORDER BY seq LIMIT ${REPLIES}`,
+ s.business,
+ a.id,
+ );
+ }
default:
fail('unknown-verb');
}
diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs
index a33c0faf..27ca798f 100644
--- a/packages/bus/src/views.mjs
+++ b/packages/bus/src/views.mjs
@@ -7,5 +7,6 @@ export function views(client) {
trail: (subject) => client.call('trail', { subject }),
decisions: (list) => client.call('decisions', { list }),
credentials: () => client.call('credentials'),
+ replies: (id) => client.call('replies', { id }),
});
}
diff --git a/packages/bus/tests/replies.test.mjs b/packages/bus/tests/replies.test.mjs
new file mode 100644
index 00000000..600e1daf
--- /dev/null
+++ b/packages/bus/tests/replies.test.mjs
@@ -0,0 +1,162 @@
+// 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' }]]);
+});
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
index 8cb07c52..6cf694a2 100644
--- a/packages/cli/src/cli.mjs
+++ b/packages/cli/src/cli.mjs
@@ -209,7 +209,7 @@ async function talk(rest, io, deps) {
if (m.in_reply_to === sent.id) answered = 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 or mosaic inbox shows it`, 1);
+ 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));
}
}
diff --git a/packages/cli/tests/verbs.test.mjs b/packages/cli/tests/verbs.test.mjs
index 41432793..6bdbc679 100644
--- a/packages/cli/tests/verbs.test.mjs
+++ b/packages/cli/tests/verbs.test.mjs
@@ -63,7 +63,22 @@ test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it
assert.deepEqual(s.calls.map((c) => c.verb), ["message.send"]);
const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]);
assert.equal(slow.code, 1);
- assert.match(slow.error, /no reply from coder within 1 s; .*mosaic inbox/);
+ // Row 81 (#1575): only mosaic talk shows a late reply; mosaic inbox lists decisions, never messages.
+ assert.equal(slow.error, "no reply from coder within 1 s; it may still come, and the next mosaic talk shows it");
+});
+
+test("a late reply the Console has listed still reaches the next talk", async (t) => {
+ const s = setup(t);
+ assert.equal((await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"])).code, 1);
+ const { id } = s.store.get("SELECT id FROM messages WHERE from_role='human' ORDER BY seq DESC LIMIT 1");
+ assert.equal(call(s.b, s.coder, "message.receive")[0].id, id);
+ const answer = call(s.b, s.coder, "message.send", { to: "human", body: "late answer", class: "RESULT", in_reply_to: id });
+ // What the Console's send receipt reads, through its reader session.
+ assert.deepEqual(s.read("replies", { id }).map((m) => m.id), [answer.id]);
+ const next = await s.run(["talk", "coder", "again", "--wait", "1", "--business", "demo"]);
+ assert.equal(next.code, 1, "no answer to the second one");
+ assert.match(next.io.out.text, new RegExp(`coder \\(RESULT, re ${id.slice(0, 8)}\\) ${answer.id.slice(0, 8)}:\nlate answer\n`));
+ assert.deepEqual(s.calls.filter((c) => c.verb === "message.read").map((c) => c.args.id), [answer.id]);
});
test("talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors", async (t) => {
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 4a7cf535..5fbac97b 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -180,8 +180,9 @@ On the page:
memory like a Reply draft. Reloading the page loses it. Send is disabled
while a send is pending.
- Sent shows the message id and links its trail, `#/trail/message/<id>`:
- the message and its deliveries. A reply is not in it. The text is cleared
- unless it was edited during the send.
+ the message and its deliveries. A reply is not in the trail; it shows
+ under the receipt (below). The text is cleared unless it was edited
+ during the send.
- Not sent shows the broker's code and keeps the draft. A redirect counts
as not sent: the Console never redirects this route, so only the sign-in
proxy does, when its session has expired. The page asks for a reload to
@@ -192,9 +193,19 @@ On the page:
unknown, here and on the board's Reply, since a proxy can answer it
after forwarding the request (Envoy's `stream_idle_timeout`).
-The role's reply comes back as a message to the human. The Console doesn't
-show it yet; that view is row 81 (#1575). Jason's next `mosaic talk` in a
-terminal prints it, within talk's limits:
+The role's reply comes back as a message to the human with `in_reply_to`
+set to the sent message's id. Since row 81 (#1575) the Sent receipt lists
+those replies, oldest first, each with its role, time, class, body and a
+trail link. It reads them with the bus `replies` read on every render, so a
+reply shows on the next ten-second refresh or Read again. "No reply yet"
+means the read returned none; "could not be read" means it failed, and the
+next refresh tries again. The bus returns at most 50; at 50 the receipt says
+only the first 50 show. The list belongs to the last Sent receipt: a new
+send starts its own, and reloading the page drops it.
+
+The Console only shows replies. It never receives a message or marks one
+delivered or read: `replies` writes no `deliveries` row. So Jason's next
+`mosaic talk` in a terminal still prints each reply, within talk's limits:
- talk receives only after it sends its own message;
- `--wait 0` returns straight after the send and receives nothing;
@@ -389,10 +400,13 @@ session reader or transport is implemented here. The session reader is
`packages/conversation`, served by the board.
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
-`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent` and
-`/api/bus/credentials` are the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
-the broker's identifier rule, and the list must be `roles` or `recent`, else
-400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
+`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent`,
+`/api/bus/credentials` and `/api/bus/replies?id=<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
+`in_reply_to` is that id, for the Sent receipt; the bus refuses an id that
+names no message (`message-not-found`, 400). `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each
list holds. `credentials` (row 60, #1549) is the bus host's credential
status: service, account, role, date and state for each credential it
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
index 379503f5..23fd4256 100644
--- a/packages/webui/src/bus.mjs
+++ b/packages/webui/src/bus.mjs
@@ -2,8 +2,8 @@
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
// Only the four verbs of the Q1 module (lead decision 56), row 57's
-// `decisions` and row 60's `credentials` reads are reachable as reads: no
-// read writes, not even seen (Q4). The one write is row 76's human
+// `decisions`, row 60's `credentials` and row 81's `replies` reads are
+// reachable as reads: no read writes, not even seen or delivered (Q4). The one write is row 76's human
// `message.send` (lead decision 95), built by the CLI only with --send. The
// bus does the proof; a server started inside an agent run gets
// `human-required` from the broker.
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index ffcc7cf5..7031368b 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -24,10 +24,11 @@
// can show what was read before, labelled as such.
const last = new Map();
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
+ // `replies` takes a message id as `id`; trail takes `subject`.
async function fetchRead(verb, subject) {
let res, body;
try {
- res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' });
+ res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store' });
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);
@@ -247,19 +248,27 @@
// a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the
// 10 s refresh redraws the form without losing it. Nothing resends: an unknown outcome stays
// unknown and the draft stays put.
- let canSend = false, sendRoles = null, sending = false, receipt = null;
+ // 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`.
+ 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.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>` : ''}`;
+ }
const draft = { to: '', body: '' };
function composerHtml() {
const roles = sendRoles ? [...new Set([...sendRoles, ...(draft.to && !sendRoles.includes(draft.to) ? [draft.to] : [])])] : null;
const options = roles ? roles.map(r => `<option value="${esc(r)}"${r === draft.to ? ' selected' : ''}>${txt(r)}${sendRoles.includes(r) ? '' : ' (not held now)'}</option>`).join('') : '';
const r = receipt;
const said = !r ? '' : r.kind === 'sent'
- ? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>`
+ ? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>${repliesHtml()}`
: r.kind === 'refused'
? `<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. A reply comes back to you as a message, which this Console does not show yet. The next <code>mosaic talk</code> in a terminal prints it, after sending its own message.</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 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>
${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>
@@ -268,6 +277,13 @@
async function composer(get) {
if (!canSend) return '';
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
+ if (receipt?.kind === 'sent') {
+ const r = receipt;
+ let rows = null;
+ try { rows = await get('replies', r.id); } catch {}
+ // A send that finished during this read has its own receipt; leave it alone.
+ if (receipt === r) replies = Array.isArray(rows) ? rows : null;
+ }
return composerHtml();
}
// Redraw only the form, keeping focus, after a send changes its state.
@@ -757,7 +773,7 @@
if (res?.type === 'opaqueredirect') {
receipt = { kind: 'refused', to, code: 'sign-in', message: 'the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again' };
} else if (res?.ok && SAFE.test(body?.id ?? '')) {
- receipt = { kind: 'sent', to, id: body.id, at: body.at };
+ receipt = { kind: 'sent', to, id: body.id, at: body.at }; replies = [];
if (draft.body === text) draft.body = ''; // keep text typed while the send was in flight
} else if (res && !res.ok && ((res.status >= 400 && res.status < 500 && res.status !== 408) || body?.sent === false)) {
receipt = { kind: 'refused', to, code: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, message: typeof body?.message === 'string' ? body.message.replace(/^not sent: /, '') : 'refused' };
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 6209f9b9..218538a1 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -47,8 +47,9 @@ async function body(req, max = 4096) {
}
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
-// (#1549) adds `credentials`: what the bus host loaded, metadata only.
-const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'];
+// (#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 DECISION_LISTS = ['roles', 'recent'];
async function busRead(res, bus, verb, search) {
let arg;
@@ -60,6 +61,10 @@ async function busRead(res, bus, verb, search) {
arg = new URLSearchParams(search).get('list');
if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
}
+ if (verb === 'replies') {
+ arg = new URLSearchParams(search).get('id');
+ if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
+ }
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const rows = await bus[verb](arg);
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
index a243ae7e..6fb74cce 100644
--- a/packages/webui/tests/bus.test.mjs
+++ b/packages/webui/tests/bus.test.mjs
@@ -150,9 +150,53 @@ test('credentials route serves the broker credential status from the reader view
assert.deepEqual(calls, [['credentials', {}]]);
});
+// Row 81 (#1575): the replies to one message, for the Console's send receipt. The bus refuses an
+// id that names no message; a malformed one never reaches it.
+test('replies route serves the replies to one message from the reader view', async t => {
+ const store = new Store(tmp(t, 'webui-bus-replies-'));
+ t.after(() => store.close());
+ const b = new Broker({ store, businesses });
+ const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
+ b.request(coder, { verb: 'role.claim' });
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const sent = b.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Please look.' } });
+ const quiet = b.request(human, { verb: 'message.send', args: { to: 'coder', body: 'No answer to this one.' } });
+ const answer = b.request(coder, { verb: 'message.send', args: { to: 'human', body: `Done. ${HOSTILE}`, class: 'RESULT', in_reply_to: sent.id } });
+ const reader = b.bindReader({ business: 'demo' });
+ const calls = [];
+ // A broker refusal comes back from the transport as a BusReadError carrying its code.
+ const call = async (verb, args = {}) => {
+ calls.push([verb, args]);
+ try { return structuredClone(b.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); }
+ };
+ const bus = views({ call });
+ const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
+ t.after(() => close(web));
+ const base = `http://127.0.0.1:${web.address().port}`;
+ const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
+ const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq');
+ const before = deliveries();
+
+ const r = await read('/api/bus/replies?id=' + sent.id);
+ assert.equal(r.status, 200); assert.equal(r.cache, 'no-store');
+ assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/);
+ assert.deepEqual(r.body.rows.map(m => [m.id, m.from_role, m.class, m.in_reply_to, m.body]), [[answer.id, 'coder', 'RESULT', sent.id, `Done. ${HOSTILE}`]]);
+ assert.deepEqual((await read('/api/bus/replies?id=' + quiet.id)).body.rows, []);
+ const unknown = await read('/api/bus/replies?id=00000000-0000-4000-8000-000000000000');
+ assert.deepEqual([unknown.status, unknown.body.error], [400, 'message-not-found']);
+ assert.deepEqual(deliveries(), before, 'no delivered or read row');
+ assert.deepEqual(calls, [['replies', { id: sent.id }], ['replies', { id: quiet.id }], ['replies', { id: '00000000-0000-4000-8000-000000000000' }]]);
+
+ // A missing or malformed id never reaches the bus; trail's `subject` is not this read's argument.
+ const n = calls.length;
+ for (const q of ['', '?id=', '?id=' + 'a'.repeat(161), '?id=-x', '?id=a%20b', '?id=%3Cb%3E', '?subject=' + sent.id])
+ assert.deepEqual([q, (await read('/api/bus/replies' + q)).body.error], [q, 'invalid-request']);
+ assert.equal(calls.length, n);
+});
+
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
const calls = [];
- const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'].map(v => [v, async () => { calls.push(v); return []; }]));
+ const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'].map(v => [v, async () => { calls.push(v); return []; }]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
@@ -160,6 +204,7 @@ test('bus routes are GET-only, have no write verb, and keep the same-origin chec
assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
assert.equal((await fetch(base + '/api/bus/decisions?list=roles', { method })).status, 405, method);
assert.equal((await fetch(base + '/api/bus/credentials', { method })).status, 405, method);
+ assert.equal((await fetch(base + '/api/bus/replies?id=m-1', { method })).status, 405, method);
}
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
assert.equal((await fetch(base + path)).status, 404, path);
@@ -192,7 +237,7 @@ test('bus refusals map to statuses and never carry a path or a stack', async t =
const unset = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/agents`);
assert.deepEqual([unset.status, (await unset.json()).error], [503, 'not-configured']);
// Row 67 (#1560): with no bus, a bad argument is still a 400; the request is checked first.
- for (const [query, status, error] of [['decisions?list=bad', 400, 'invalid-request'], ['decisions?list=roles', 503, 'not-configured'], ['trail?subject=%3C', 400, 'invalid-request']]) {
+ for (const [query, status, error] of [['decisions?list=bad', 400, 'invalid-request'], ['decisions?list=roles', 503, 'not-configured'], ['trail?subject=%3C', 400, 'invalid-request'], ['replies?id=%3C', 400, 'invalid-request'], ['replies?id=m-1', 503, 'not-configured']]) {
const r = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/${query}`);
assert.deepEqual([query, r.status, (await r.json()).error], [query, status, error]);
}
@@ -250,6 +295,7 @@ test('busReader takes --business, else the live bus host, else refuses', async t
assert.equal((await pinned.inbox()).request.business, 'pinned');
assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } });
assert.deepEqual((await pinned.credentials()).request, { business: 'pinned', verb: 'credentials', args: {} });
+ assert.deepEqual((await pinned.replies('m-1')).request, { business: 'pinned', verb: 'replies', args: { id: 'm-1' } });
const reader = busReader({ dataRoot, socket: 'sock', cli });
const code = () => reader.agents().then(() => 'resolved', e => e.code);
diff --git a/packages/webui/tests/send-browser.test.mjs b/packages/webui/tests/send-browser.test.mjs
index dd7c5668..675c813e 100644
--- a/packages/webui/tests/send-browser.test.mjs
+++ b/packages/webui/tests/send-browser.test.mjs
@@ -13,9 +13,11 @@ import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
+import { BusReadError } from '../src/bus.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
+const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
const businesses = {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
@@ -100,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'), /does not show yet\. The next mosaic talk in a terminal prints it, after sending its own message\./);
+ 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.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.
@@ -193,3 +195,135 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
rmSync(root, { recursive: true, force: true });
}
});
+
+// 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.
+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 });
+ const caps = {};
+ for (const role of ['pm', 'coder']) b0.request(caps[role] = b0.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', address: `${role}-run` }), { verb: 'role.claim' });
+ const human = b0.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const sent = b0.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Please look at row 81.' } });
+ const other = b0.request(human, { verb: 'message.send', args: { to: 'pm', body: 'Another question' } });
+ const reader = b0.bindReader({ business: 'demo' });
+ // A broker refusal comes back from the transport as a BusReadError carrying its code. While
+ // `hold` is set, a replies read waits for it, counted in `held`.
+ let hold = null, held = 0;
+ const bus = views({
+ call: async (verb, args = {}) => {
+ if (verb === 'replies' && hold) { held++; await hold.promise; }
+ try { return structuredClone(b0.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); }
+ },
+ });
+ const f = await fixture(), b = await browser();
+ const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
+ const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
+ const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
+ const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
+ const send = async (id, body) => {
+ await set('#send-body', body);
+ await b.evaluate(`window.__answers.push(${JSON.stringify({ status: 200, body: { id, at: new Date().toISOString() } })}), true`);
+ await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
+ await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"');
+ };
+ const refresh = async () => {
+ await b.evaluate('(()=>{document.querySelector("#send-section").dataset.before="1";document.querySelector("#refresh").click();return true})()');
+ await wait('!document.querySelector("#send-section")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
+ };
+ const items = () => b.evaluate('[...document.querySelectorAll("#send-section .s1-replies li")].map(li => [...li.querySelectorAll("p")].map(p => p.textContent).concat(li.querySelector("a").getAttribute("href")))');
+ const reply = (role, to, body) => b0.request(caps[role], { verb: 'message.send', args: { to: 'human', body, class: 'RESULT', ...(to ? { in_reply_to: to } : {}) } });
+ const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq').map(d => ({ ...d }));
+ const NONE = 'No reply yet. One shows here when the role answers, as the page refreshes.';
+ try {
+ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
+ await b.viewport(1280);
+ await b.navigate(`${base}#/inbox`);
+ await wait('document.querySelector("#send-form")');
+ await set('#send-to', 'coder');
+
+ // Straight after the send there is no reply.
+ await send(sent.id, 'Please look at row 81.');
+ assert.match(await text('#send-section .receipt'), new RegExp(`^Sent to coder .*, message ${sent.id.slice(0, 8)}\\. Its trail\\.$`));
+ assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
+ assert.deepEqual(await items(), []);
+
+ // Replies to the sent message show on Read again, oldest first, as text. A reply to another
+ // message and a message with no in_reply_to don't.
+ const first = reply('coder', sent.id, `Done: 3 files. ${HOSTILE}`);
+ reply('pm', other.id, 'Another answer');
+ reply('pm', null, 'fyi');
+ const second = reply('pm', sent.id, 'Second look.');
+ let before = deliveries();
+ await refresh();
+ assert.deepEqual(deliveries(), before, 'no delivered or read row');
+ assert.equal(await text('#send-section h3'), 'Replies 2');
+ const two = await items();
+ assert.deepEqual(two.map(([meta, , href]) => [meta.split(' ')[0], / · RESULT · trail$/.test(meta), href]), [['coder', true, `#/trail/message/${first.id}`], ['pm', true, `#/trail/message/${second.id}`]]);
+ assert.match(two[0][1], /^Done: 3 files\. <script>window\.injected=1<\/script><img src=x onerror="window\.injected=2">/);
+ assert.equal(two[1][1], 'Second look.');
+ assert.equal(await b.evaluate('!!window.injected || !!document.querySelector("#send-section script, #send-section img")'), false);
+ assert.match(await text('#send-section .receipt'), /^Sent to coder /);
+ assert.doesNotMatch(await text('#send-section'), /The first 50 replies/);
+
+ // The page's own refresh reads them too, with no click.
+ const third = reply('coder', sent.id, 'Third.');
+ before = deliveries();
+ 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
+ // 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' });
+ assert.deepEqual(got.filter(m => m.in_reply_to === sent.id).map(m => m.id), [first.id, second.id, third.id]);
+ for (const m of got) b0.request(human, { verb: 'message.read', args: { id: m.id } });
+ await refresh();
+ assert.equal((await items()).length, 3);
+
+ // The broker's cap: 51 replies show the first 50 and say so.
+ for (let i = 0; i < 48; i++) reply(i % 2 ? 'pm' : 'coder', sent.id, `reply ${i}`);
+ await refresh();
+ const capped = await items();
+ assert.equal(capped.length, 50);
+ assert.deepEqual([capped[0][1].slice(0, 5), capped[3][1], capped[49][1]], ['Done:', 'reply 0', 'reply 46']);
+ assert.equal(await text('#send-section h3'), 'Replies 50');
+ assert.match(await text('#send-section'), /The first 50 replies show here\./);
+
+ // A sent id the bus doesn't know: its replies can't be read, which the receipt says; the
+ // Inbox stays fresh.
+ 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.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\.$/);
+
+ // A new send starts its own list.
+ await send(sent.id, 'Once more.');
+ assert.deepEqual(await items(), []);
+ await refresh();
+ assert.equal((await items()).length, 50);
+
+ // A send that finishes while a replies read is out keeps its own list: that read's answer is
+ // the earlier message's replies, so it is dropped and the next render reads the new one's.
+ let release;
+ hold = { promise: new Promise(r => { release = r; }) };
+ await b.evaluate('(()=>{document.querySelector("#bus-view h1").dataset.before="1";document.querySelector("#refresh").click();return true})()');
+ for (let i = 0; i < 100 && !held; i++) await new Promise(r => setTimeout(r, 50));
+ assert.equal(held, 1);
+ await send(other.id, 'Another?');
+ hold = null; release();
+ await wait('!document.querySelector("#bus-view h1")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
+ assert.match(await text('#send-section .receipt'), new RegExp(`, message ${other.id.slice(0, 8)}\\. Its trail\\.$`));
+ assert.deepEqual(await items(), []);
+ assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
+ await refresh();
+ assert.deepEqual((await items()).map(i => i[1]), ['Another answer']);
+ } finally {
+ await b.close(); await close(web); await f.close(); store.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
@@ -0,0 +1,12 @@
ccadda33cc22f9a680a8bfbf705ca598b46ee212e687ab98ce0ee80f5664c036 packages/bus/README.md
4e532c9f53efda4db9dfff869746bcb99770c5ea956606637feb8d46f4ee31dd packages/bus/src/broker.mjs
c1258cc5bdb477ae855d918390e273f89d6ce82483b7017c69da2f943227290a packages/bus/src/views.mjs
75a3544afa840ef026140435d402a9a525b2677c74c5783cea240f4e6e22ab93 packages/bus/tests/replies.test.mjs
fda4c7fccb79d815557aa531b8c2e4b28d32b29d980d5782899faae5737ec269 packages/cli/src/cli.mjs
37139c916d32cb48e13dd42f2d351533112e010f496560f4740db974fd27fd1d packages/cli/tests/verbs.test.mjs
fce3222d308fea2c3553d77b81eb482bc0c866fe640c3de414c68302b11ab8a2 packages/webui/README.md
c9cb39e5d9a9c4c749818fca344e1c7f4e561ee0705d639a4f5c0b03663d1c81 packages/webui/src/bus.mjs
e7c8f92a9abfa65d420499940d3ee7b43274794ae3095eb7230ee9a1e3e834e1 packages/webui/src/public/bus.js
bc2274c490fe9d6de11319f1cac8bd2d35659d82701991133d0954fb1c2e9bf4 packages/webui/src/serve.mjs
f2b69e407c21295d737b25606bb1d65f6f363184bab458b9da5a129f1bb0b19f packages/webui/tests/bus.test.mjs
2c0ed3d134695fd754c77cd9540ddd551994703a9eef494f2fafd8ddf6507616 packages/webui/tests/send-browser.test.mjs
@@ -0,0 +1,32 @@
2026-10-12T18:29:24Z
== node bus
ℹ tests 102
ℹ pass 102
ℹ fail 0
node-bus rc=0
== node webui
ℹ tests 83
ℹ pass 83
ℹ fail 0
node-webui rc=0
== node control-board
ℹ tests 124
ℹ pass 124
ℹ fail 0
node-control-board rc=0
== node cli
ℹ tests 115
ℹ pass 115
ℹ fail 0
node-cli rc=0
test-auth rc=0
test-conductor rc=0
test-config rc=0
test-discord rc=0
test-extension-package rc=0
test-foundation rc=0
test-queue rc=0
test-release rc=0
test-task rc=0
GATE-DONE
2026-10-12T18:35:52Z
+38
View File
@@ -0,0 +1,38 @@
# Row 81 mutants: each swaps one exact string in the candidate copy, runs the named tests,
# restores the file, and reports killed (tests fail) or SURVIVED.
import subprocess, sys, os
M = os.path.expanduser('~/dewey-scratch/r81/mut')
env = dict(os.environ, TMPDIR=os.path.expanduser('~/dewey-scratch/tmp'))
B, W, C = 'packages/bus/tests/replies.test.mjs', 'packages/webui/tests/bus.test.mjs', 'packages/cli/tests/verbs.test.mjs'
SB = 'packages/webui/tests/send-browser.test.mjs'
muts = [
('broker: no human-required', 'packages/bus/src/broker.mjs', " case 'replies': {\n if (!s.human && !s.reader) fail('human-required');\n", " case 'replies': {\n", [B]),
('broker: no message-not-found', 'packages/bus/src/broker.mjs', " fail('message-not-found');\n return this.#store.all(\n `SELECT * FROM messages WHERE business=? AND in_reply_to=?", " (void 0);\n return this.#store.all(\n `SELECT * FROM messages WHERE business=? AND in_reply_to=?", [B]),
('broker: newest first', 'packages/bus/src/broker.mjs', 'in_reply_to=? ORDER BY seq LIMIT', 'in_reply_to=? ORDER BY seq DESC LIMIT', [B]),
('broker: cap 51', 'packages/bus/src/broker.mjs', 'LIMIT ${REPLIES}`', 'LIMIT ${REPLIES + 1}`', [B]),
('broker: reader not allowed', 'packages/bus/src/broker.mjs', "'credentials', 'replies'", "'credentials'", [B]),
('broker: no strict keys', 'packages/bus/src/broker.mjs', " keys(a, ['id'], ['id']);\n identifier(a.id);\n if (!this.#store.get('SELECT id FROM messages", " if (!this.#store.get('SELECT id FROM messages", [B]),
('views: subject arg', 'packages/bus/src/views.mjs', "client.call('replies', { id })", "client.call('replies', { subject: id })", [B, W]),
('serve: no id check', 'packages/webui/src/serve.mjs', " if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });\n", "", [W]),
('serve: replies not a verb', 'packages/webui/src/serve.mjs', ", 'replies'", "", [W]),
('page: query as subject', 'packages/webui/src/public/bus.js', "verb === 'replies' ? 'id' : 'subject'", "'subject'", [SB]),
('page: no replies read', 'packages/webui/src/public/bus.js', "try { rows = await get('replies', r.id); } catch {}", "rows = [];", [SB]),
('page: no reset on send', 'packages/webui/src/public/bus.js', "receipt = { kind: 'sent', to, id: body.id, at: body.at }; replies = [];", "receipt = { kind: 'sent', to, id: body.id, at: body.at };", [SB]),
('page: no stale guard', 'packages/webui/src/public/bus.js', 'if (receipt === r) replies', 'if (true) replies', [SB]),
('page: cap note off by one', 'packages/webui/src/public/bus.js', 'replies.length >= REPLIES ?', 'replies.length > REPLIES ?', [SB]),
('page: body unescaped', 'packages/webui/src/public/bus.js', '<p class="s1-q">${txt(m.body)}</p>', '<p class="s1-q">${m.body}</p>', [SB]),
('page: failure shown as none', 'packages/webui/src/public/bus.js', "if (receipt === r) replies = Array.isArray(rows) ? rows : null;", "if (receipt === r) replies = Array.isArray(rows) ? rows : [];", [SB]),
('cli: old message', 'packages/cli/src/cli.mjs', 'and the next mosaic talk shows it`', 'and the next mosaic talk or mosaic inbox shows it`', [C]),
]
only = sys.argv[1:]
for name, f, old, new, tests in muts:
if only and not any(o in name for o in only): continue
p = os.path.join(M, f); src = open(p).read()
n = src.count(old)
if n != 1: print(f'BAD-ANCHOR ({n}) {name}', flush=True); continue
open(p, 'w').write(src.replace(old, new))
try:
r = subprocess.run(['node', '--test', *tests], cwd=M, env=env, capture_output=True, text=True, timeout=600)
finally:
open(p, 'w').write(src)
print(('killed ' if r.returncode else 'SURVIVED') + f' {name}', flush=True)
+18
View File
@@ -0,0 +1,18 @@
killed broker: no human-required
killed broker: no message-not-found
killed broker: newest first
killed broker: cap 51
killed broker: reader not allowed
killed broker: no strict keys
killed views: subject arg
BAD-ANCHOR (0) serve: no id check
killed serve: replies not a verb
killed page: query as subject
killed page: no replies read
killed page: no reset on send
killed page: no stale guard
killed page: cap note off by one
killed page: body unescaped
killed page: failure shown as none
killed cli: old message
killed serve: no id check
@@ -0,0 +1,23 @@
== packages/bus/tests/replies.test.mjs
✖ packages/bus/tests/replies.test.mjs (58.858432ms)
== packages/webui/tests/bus.test.mjs
✖ busReader takes --business, else the live bus host, else refuses (233.733697ms)
✖ bus refusals map to statuses and never carry a path or a stack (40.96773ms)
✖ bus routes are GET-only, have no write verb, and keep the same-origin checks (16.620209ms)
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (288.187057ms)
✔ credentials route serves the broker credential status from the reader view (60.820513ms)
✔ decisions route serves the roles and recent lists from the reader view (173.558613ms)
✔ humanCall kills a transport that runs past its timeout (533.122796ms)
✔ humanCall speaks the human transport protocol without blocking the server (1891.16535ms)
✖ replies route serves the replies to one message from the reader view (84.575767ms)
✔ serve refuses a --business value that is not a business id (526.611486ms)
== packages/webui/tests/send-browser.test.mjs
✖ a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read (1096.178087ms)
✖ the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx (2121.36807ms)
== packages/cli/tests/verbs.test.mjs
✖ a late reply the Console has listed still reaches the next talk (1113.054399ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (44.933911ms)
✔ launches off and on go to the broker and change the business's launch state (60.242305ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (203.491834ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (89.29236ms)
✖ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1087.565745ms)