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]>
12 KiB
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 (keysstrict,identifieron the id). Returns the business's messages whosein_reply_tois that id,ORDER BY seq,LIMIT REPLIES(exported, 50). Human and reader sessions only; a role session getshuman-required, the same gate asdecisionsandcredentials. It is on the reader allowlist. An id naming no message in the session's business refusesmessage-not-foundwith the same querymessage.senduses forin_reply_to(SELECT id FROM messages WHERE id=? AND business=?), and the refusal is recorded like every other. It writes nothing: nodeliveriesrow, no event on success. - views passes it through:
replies(id)calls('replies', { id }). - webui server:
GET /api/bus/replies?id=<message id>.repliesjoinsBUS_VERBS; the id must passsubjectOk(the broker's identifier rule) or the server answers 400invalid-requestwithout calling the bus.message-not-foundalready 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 withreplieson 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, throughtxtlike every bus string. "No reply yet" when the list is empty; "could not be read" when the read failed, withmosaic talknamed as the other place they print; a note at 50 that only the first 50 show. The receipt paragraph itself is unchanged, so.receipttext 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,repliesin the reader capability list, a paragraph in "Human and reader paths", andread.replies(messageId)in the shared-reads snippet.
Decisions and tradeoffs
- A new verb, not a
trailfield (the brief left this to me).trailtakes 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 intrailwould 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/trailare also open to role sessions;decisionsandcredentialsare not. I followed the brief's wording and the newer two: a role getshuman-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
repliesas 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, therepliescase.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_toand a reply to another message are not. Each row equals themessagesrowtrailgives for the reply's id. Thedeliveriesrows and the row counts of six tables are the same before and after; the reply's own deliveries are onlyrouted. - 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.refusedevent. 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 getshuman-required, including for a message it sent; the reader still can'tmessage.receive. - views passes replies through.
- 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
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 400message-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 onreplies), the no-bus refusals (replies?id=%3C400,replies?id=m-1503), thebusReaderpassthrough.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.injectedunset), the unrelated messages absent, deliveries unchanged; a third reply shows on the page's own ten-second refresh with no click; the human'smessage.receivethen 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,replieslists 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.
decisionsandcredentialshave rows there, so I added one forreplies. One line; drop it if the brief's file boundary is strict. packages/cli/README.mdlines 61-65 say "the reply still lands in the inbox". That is the human's message inbox (whatmessage.receivereturns), notmosaic 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 nextmosaic talkprints it".- Equivalent mutant not run: dropping
business=?from the replies query.message.sendrefuses anin_reply_tooutside 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.